فهرست منبع

pstats: Add macOS port

Closes #1531
rdb 2 سال پیش
والد
کامیت
71154492b9
37فایلهای تغییر یافته به همراه7926 افزوده شده و 2 حذف شده
  1. 8 2
      makepanda/makepanda.py
  2. 47 0
      pandatool/src/mac-stats/Info.plist
  3. 45 0
      pandatool/src/mac-stats/cocoa_compat.h
  4. 19 0
      pandatool/src/mac-stats/macStats.h
  5. 73 0
      pandatool/src/mac-stats/macStats.mm
  6. 37 0
      pandatool/src/mac-stats/macStatsAppDelegate.h
  7. 201 0
      pandatool/src/mac-stats/macStatsAppDelegate.mm
  8. 60 0
      pandatool/src/mac-stats/macStatsChartMenu.h
  9. 246 0
      pandatool/src/mac-stats/macStatsChartMenu.mm
  10. 32 0
      pandatool/src/mac-stats/macStatsChartMenuDelegate.h
  11. 61 0
      pandatool/src/mac-stats/macStatsChartMenuDelegate.mm
  12. 88 0
      pandatool/src/mac-stats/macStatsFlameGraph.h
  13. 871 0
      pandatool/src/mac-stats/macStatsFlameGraph.mm
  14. 139 0
      pandatool/src/mac-stats/macStatsGraph.h
  15. 491 0
      pandatool/src/mac-stats/macStatsGraph.mm
  16. 31 0
      pandatool/src/mac-stats/macStatsGraphView.h
  17. 158 0
      pandatool/src/mac-stats/macStatsGraphView.mm
  18. 42 0
      pandatool/src/mac-stats/macStatsGraphViewController.h
  19. 224 0
      pandatool/src/mac-stats/macStatsGraphViewController.mm
  20. 58 0
      pandatool/src/mac-stats/macStatsLabel.h
  21. 137 0
      pandatool/src/mac-stats/macStatsLabel.mm
  22. 57 0
      pandatool/src/mac-stats/macStatsLabelStack.h
  23. 174 0
      pandatool/src/mac-stats/macStatsLabelStack.mm
  24. 117 0
      pandatool/src/mac-stats/macStatsMonitor.h
  25. 650 0
      pandatool/src/mac-stats/macStatsMonitor.mm
  26. 84 0
      pandatool/src/mac-stats/macStatsPianoRoll.h
  27. 764 0
      pandatool/src/mac-stats/macStatsPianoRoll.mm
  28. 41 0
      pandatool/src/mac-stats/macStatsScaleArea.h
  29. 50 0
      pandatool/src/mac-stats/macStatsScaleArea.mm
  30. 82 0
      pandatool/src/mac-stats/macStatsServer.h
  31. 732 0
      pandatool/src/mac-stats/macStatsServer.mm
  32. 90 0
      pandatool/src/mac-stats/macStatsStripChart.h
  33. 964 0
      pandatool/src/mac-stats/macStatsStripChart.mm
  34. 100 0
      pandatool/src/mac-stats/macStatsTimeline.h
  35. 932 0
      pandatool/src/mac-stats/macStatsTimeline.mm
  36. 16 0
      pandatool/src/mac-stats/macstats_composite1.mm
  37. 5 0
      pandatool/src/pstatserver/pStatStripChart.cxx

+ 8 - 2
makepanda/makepanda.py

@@ -1077,6 +1077,9 @@ if (COMPILER=="GCC"):
         LibName("COCOA", "-framework Cocoa")
         # Fix for a bug in OSX Leopard:
         LibName("GL", "-dylib_file /System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib:/System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib")
+        # When using pre-11.0 SDKs, for PStats
+        if os.path.basename(SDK["MACOSX"]).startswith("MacOSX10."):
+            LibName("COCOA", "-Wl,-U,_OBJC_CLASS_$_NSTrackingSeparatorToolbarItem")
 
         # Temporary exceptions to removal of this flag
         if not PkgSkip("FFMPEG"):
@@ -5911,10 +5914,13 @@ if not PkgSkip("PANDATOOL"):
 # DIRECTORY: pandatool/src/gtk-stats/
 #
 
-if not PkgSkip("PANDATOOL") and (GetTarget() == 'windows' or not PkgSkip("GTK3")):
+if not PkgSkip("PANDATOOL") and (GetTarget() in ('windows', 'darwin') or not PkgSkip("GTK3")):
     if GetTarget() == 'windows':
         OPTS=['DIR:pandatool/src/win-stats']
         TargetAdd('pstats_composite1.obj', opts=OPTS, input='winstats_composite1.cxx')
+    elif GetTarget() == 'darwin':
+        OPTS=['DIR:pandatool/src/mac-stats']
+        TargetAdd('pstats_composite1.obj', opts=OPTS, input='macstats_composite1.mm')
     else:
         OPTS=['DIR:pandatool/src/gtk-stats', 'GTK3']
         TargetAdd('pstats_composite1.obj', opts=OPTS, input='gtkstats_composite1.cxx')
@@ -5923,7 +5929,7 @@ if not PkgSkip("PANDATOOL") and (GetTarget() == 'windows' or not PkgSkip("GTK3")
     TargetAdd('pstats.exe', input='libp3progbase.lib')
     TargetAdd('pstats.exe', input='libp3pandatoolbase.lib')
     TargetAdd('pstats.exe', input=COMMON_PANDA_LIBS)
-    TargetAdd('pstats.exe', opts=['SUBSYSTEM:WINDOWS', 'WINCOMCTL', 'WINCOMDLG', 'WINSOCK', 'WINIMM', 'WINGDI', 'WINKERNEL', 'WINOLDNAMES', 'WINUSER', 'WINMM', 'UXTHEME', 'GTK3'])
+    TargetAdd('pstats.exe', opts=['SUBSYSTEM:WINDOWS', 'WINCOMCTL', 'WINCOMDLG', 'WINSOCK', 'WINIMM', 'WINGDI', 'WINKERNEL', 'WINOLDNAMES', 'WINUSER', 'WINMM', 'UXTHEME', 'GTK3', 'COCOA', 'CARBON', 'QUARTZ'])
 
 #
 # DIRECTORY: pandatool/src/xfileprogs/

+ 47 - 0
pandatool/src/mac-stats/Info.plist

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+        <key>CFBundleDevelopmentRegion</key>
+        <string>English</string>
+        <key>CFBundleIdentifier</key>
+        <string>org.panda3d.pstats</string>
+        <key>CFBundleExecutable</key>
+        <string>pstats</string>
+        <key>CFBundleName</key>
+        <string>PStats</string>
+        <key>CFBundleDisplayName</key>
+        <string>PStats</string>
+        <key>CFBundleInfoDictionaryVersion</key>
+        <string>6.0</string>
+        <key>CFBundlePackageType</key>
+        <string>APPL</string>
+        <key>LSHasLocalizedDisplayName</key>
+        <false/>
+        <key>NSAppleScriptEnabled</key>
+        <false/>
+        <key>NSPrincipalClass</key>
+        <string>NSApplication</string>
+        <key>CFBundleDocumentTypes</key>
+        <array>
+                <dict>
+                        <key>CFBundleTypeName</key>
+                        <string>PStats session file</string>
+                        <key>CFBundleTypeRole</key>
+                        <string>Editor</string>
+                        <key>LSHandlerRank</key>
+                        <string>Owner</string>
+                        <key>LSTypeIsPackage</key>
+                        <false/>
+                        <key>CFBundleTypeExtensions</key>
+                        <array>
+                                <string>pstats</string>
+                        </array>
+                        <key>CFBundleTypeMIMETypes</key>
+                        <array>
+                                <string>application/vnd.panda3d.pstats</string>
+                        </array>
+                </dict>
+        </array>
+</dict>
+</plist>

+ 45 - 0
pandatool/src/mac-stats/cocoa_compat.h

@@ -0,0 +1,45 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file cocoa_compat.h
+ * @author rdb
+ * @date 2023-08-28
+ */
+
+#ifndef COCOA_COMPAT_H
+#define COCOA_COMPAT_H
+
+#import <Cocoa/Cocoa.h>
+
+// Allow building with older SDKs.
+#if __MAC_OS_X_VERSION_MAX_ALLOWED < 110000
+typedef NS_ENUM(NSInteger, NSWindowToolbarStyle) {
+  NSWindowToolbarStyleAutomatic,
+  NSWindowToolbarStyleExpanded,
+  NSWindowToolbarStylePreference,
+  NSWindowToolbarStyleUnified,
+  NSWindowToolbarStyleUnifiedCompact
+} API_AVAILABLE(macos(11.0));
+
+API_AVAILABLE(macos(11.0)) API_UNAVAILABLE(ios)
+@interface NSTrackingSeparatorToolbarItem : NSToolbarItem
++ (instancetype)trackingSeparatorToolbarItemWithIdentifier:(NSString *)identifier splitView:(NSSplitView *)splitView dividerIndex:(NSInteger)dividerIndex API_UNAVAILABLE(ios);
+@property (strong) NSSplitView *splitView API_UNAVAILABLE(ios);
+@property NSInteger dividerIndex API_UNAVAILABLE(ios);
+@end
+
+#endif  // __MAC_OS_X_VERSION_MAX_ALLOWED
+
+#if __MAC_OS_X_VERSION_MAX_ALLOWED < 101400
+@protocol NSViewToolTipOwner <NSObject>
+- (NSString *)view:(NSView *)view stringForToolTip:(NSToolTipTag)tag point:(NSPoint)point userData:(nullable void *)data;
+@end
+
+#endif  // __MAC_OS_X_VERSION_MAX_ALLOWED
+
+#endif  // COCOA_COMPAT_H

+ 19 - 0
pandatool/src/mac-stats/macStats.h

@@ -0,0 +1,19 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStats.h
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#ifndef MACSTATS_H
+#define MACSTATS_H
+
+#include "pStatServer.h"
+
+#endif

+ 73 - 0
pandatool/src/mac-stats/macStats.mm

@@ -0,0 +1,73 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStats.mm
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#include "pandatoolbase.h"
+#include "macStats.h"
+#include "macStatsServer.h"
+#include "config_pstatclient.h"
+
+#include <Carbon/Carbon.h>
+#include <objc/runtime.h>
+
+extern "C" {
+  OSStatus CPSSetProcessName(ProcessSerialNumber *psn, char *name);
+};
+
+@implementation NSBundle(swizzle)
+- (NSString *)__bundleIdentifier {
+  if (self == [NSBundle mainBundle]) {
+    return @"org.panda3d.pstats";
+  } else {
+    return [self __bundleIdentifier];
+  }
+}
+@end
+
+void
+keyboard_interrupt(int sig) {
+  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
+    [NSApp terminate:NSApp];
+  });
+}
+
+int
+main(int argc, char *argv[]) {
+  // This hack is necessary to allow showing notifications when run as a console app.
+  Class cls = objc_getClass("NSBundle");
+  if (cls) {
+    method_exchangeImplementations(class_getInstanceMethod(cls, @selector(bundleIdentifier)),
+                                   class_getInstanceMethod(cls, @selector(__bundleIdentifier)));
+  }
+
+  // Set the bundle name of the application.
+  ProcessSerialNumber psn;
+  GetCurrentProcess(&psn);
+  CPSSetProcessName(&psn, (char *)"PStats");
+
+  @autoreleasepool {
+    // Create the server application.
+    MacStatsServer *server = new MacStatsServer;
+
+    // Register a SIGINT handler to terminate the application correctly.
+    // Otherwise, notifications may linger in the notification center.
+    struct sigaction act = {};
+    act.sa_handler = &keyboard_interrupt;
+    act.sa_flags = SA_RESETHAND;
+    sigaction(SIGINT, &act, nullptr);
+
+    // Get lost in the Cocoa main loop.
+    server->run(argc, argv);
+  }
+
+  return 0;
+}

+ 37 - 0
pandatool/src/mac-stats/macStatsAppDelegate.h

@@ -0,0 +1,37 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsAppDelegate.h
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#ifndef MACSTATSAPPDELEGATE_H
+#define MACSTATSAPPDELEGATE_H
+
+#import <Foundation/Foundation.h>
+#import <AppKit/AppKit.h>
+
+class MacStatsServer;
+
+@interface MacStatsAppDelegate : NSObject<NSApplicationDelegate, NSUserNotificationCenterDelegate> {
+  @private
+    MacStatsServer *_server;
+    NSTimer *_timer;
+}
+
+- (id)initWithServer:(MacStatsServer *)server;
+- (void)applicationDidFinishLaunching:(NSApplication *)sender;
+- (BOOL)applicationShouldTerminate:(NSApplication *)sender;
+- (void)applicationWillTerminate:(NSApplication *)sender;
+- (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center
+                               shouldPresentNotification:(NSUserNotification *)notification;
+
+@end
+
+#endif

+ 201 - 0
pandatool/src/mac-stats/macStatsAppDelegate.mm

@@ -0,0 +1,201 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsAppDelegate.mm
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#include "macStatsAppDelegate.h"
+#include "macStatsServer.h"
+#include "pStatGraph.h"
+
+@implementation MacStatsAppDelegate
+
+- (id)initWithServer:(MacStatsServer *)server {
+  if (self = [super init]) {
+    _server = server;
+    _timer = nil;
+  }
+
+  return self;
+}
+
+- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app {
+  // Squelches an annoying warning.
+  return YES;
+}
+
+- (BOOL)application:(NSApplication *)sender
+           openFile:(NSString *)filename {
+  Filename fn([filename UTF8String]);
+  fn.set_binary();
+  return _server->open_session(fn);
+}
+
+- (void)applicationDidFinishLaunching:(NSApplication *)sender {
+  // Set this object as delegate for user notifications.
+  {
+    NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+    center.delegate = self;
+  }
+
+  // Register default preferences.
+  NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
+  NSDictionary *dict = [NSDictionary
+    dictionaryWithObjects:@[@"", [NSNumber numberWithBool:YES], [NSNumber numberWithInt:PStatGraph::GBU_ms]]
+                  forKeys:@[@"Appearance", @"ShowStatusItem", @"TimeUnits"]];
+  [defaults registerDefaults:dict];
+
+  if (_server != nil) {
+    // Apply preferences.
+    [self applyDefaults:nil];
+
+    // Create a timer to poll the server.
+    _timer = [NSTimer scheduledTimerWithTimeInterval:0.2
+      target:self
+      selector:@selector(pollServer)
+      userInfo:nil
+      repeats:YES];
+
+    // Start new session if we don't have one yet.
+    if (_server->get_monitor() == nullptr) {
+      _server->new_session();
+    }
+  }
+
+  // Watch for defaults change.
+  {
+    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+    [center addObserver:self
+               selector:@selector(applyDefaults:)
+                   name:NSUserDefaultsDidChangeNotification
+                 object:nil];
+  }
+}
+
+- (void)applyDefaults:(NSNotification *)notification {
+  if (_server != nil) {
+    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
+    _server->set_show_status_item([defaults boolForKey:@"ShowStatusItem"]);
+    _server->set_appearance([defaults stringForKey:@"Appearance"]);
+    _server->set_time_units([defaults integerForKey:@"TimeUnits"]);
+  }
+}
+
+- (void)pollServer {
+  _server->poll();
+}
+
+- (BOOL)applicationShouldTerminate:(NSApplication *)sender {
+  if (_server != nil) {
+    return _server->close_session();
+  }
+  return YES;
+}
+
+- (void)applicationWillTerminate:(NSApplication *)sender {
+  if (_timer != nil) {
+    [_timer invalidate];
+    _timer = nil;
+  }
+
+  if (_server != nil) {
+    delete _server;
+    _server = nil;
+  }
+}
+
+- (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center
+                               shouldPresentNotification:(NSUserNotification *)notification {
+  return YES;
+}
+
+- (void)userNotificationCenter:(NSUserNotificationCenter *)center
+       didActivateNotification:(NSUserNotification *)notification {
+  NSUserNotificationAction *action = notification.additionalActivationAction;
+  if (action != nil) {
+    NSString *ident = action.identifier;
+    if ([ident isEqual:@"quit"]) {
+      [NSApp terminate:self];
+    }
+    else if ([ident isEqual:@"new"]) {
+      _server->new_session();
+    }
+    else if ([ident isEqual:@"open"]) {
+      _server->open_session();
+    }
+    else if ([ident isEqual:@"openLast"]) {
+      _server->open_last_session();
+    }
+  }
+}
+
+- (void)handleNewSession:(NSMenuItem *)item {
+  _server->new_session();
+}
+
+- (void)handleOpenSession:(NSMenuItem *)item {
+  _server->open_session();
+}
+
+- (void)handleOpenLastSession:(NSMenuItem *)item {
+  _server->open_last_session();
+}
+
+- (void)handleSaveSession:(NSMenuItem *)item {
+  _server->save_session();
+}
+
+- (void)handleCloseSession:(NSMenuItem *)item {
+  _server->close_session();
+}
+
+- (void)handleExportSession:(NSMenuItem *)item {
+  _server->export_session();
+}
+
+- (void)handleToggleSettingsBool:(NSMenuItem *)item {
+  [[NSUserDefaults standardUserDefaults] setBool:(item.state != NSOnState) forKey:item.representedObject];
+}
+
+- (void)handleSettingsInteger:(NSMenuItem *)item {
+  [[NSUserDefaults standardUserDefaults] setInteger:item.tag forKey:item.representedObject];
+}
+
+- (void)handleSettingsAppearance:(NSMenuItem *)item {
+  [[NSUserDefaults standardUserDefaults] setObject:item.representedObject forKey:@"Appearance"];
+}
+
+- (void)handleSpeed:(NSMenuItem *)item {
+  MacStatsMonitor *monitor = _server->get_monitor();
+  if (monitor != nullptr) {
+    monitor->set_scroll_speed(item.tag);
+  }
+}
+
+- (void)handlePause:(NSMenuItem *)item {
+  MacStatsMonitor *monitor = _server->get_monitor();
+  if (monitor != nullptr) {
+    monitor->set_pause(!item.state);
+  }
+}
+
+- (void)handleClickStatusItem:(id)sender {
+  [NSApp activateIgnoringOtherApps:YES];
+}
+
+- (void)handleChooseCollectorColor:(NSColorPanel *)panel {
+  MacStatsMonitor *monitor = _server->get_monitor();
+  if (monitor != nullptr) {
+    NSColor *color = panel.color;
+    monitor->handle_choose_collector_color(LRGBColor(color.redComponent, color.greenComponent, color.blueComponent));
+  }
+}
+
+@end

+ 60 - 0
pandatool/src/mac-stats/macStatsChartMenu.h

@@ -0,0 +1,60 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsChartMenu.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSCHARTMENU_H
+#define MACSTATSCHARTMENU_H
+
+#include "pandatoolbase.h"
+#include "macStatsMonitor.h"
+
+#include <Cocoa/Cocoa.h>
+
+class PStatView;
+class PStatViewLevel;
+
+/**
+ * A pulldown menu of charts available for a particular thread.
+ */
+class MacStatsChartMenu {
+public:
+  MacStatsChartMenu(MacStatsMonitor *monitor, int thread_index);
+  ~MacStatsChartMenu();
+
+  int get_thread_index() const { return _thread_index; }
+
+  void add_to_menu(NSMenu *menu, int position);
+  void remove_from_menu(NSMenu *menu);
+
+  void check_update();
+  void do_update();
+
+private:
+  bool add_view(NSMenu *parent_menu, const PStatViewLevel *view_level,
+                bool show_level, int insert_at);
+  NSMenuItem *make_menu_item(NSMenu *parent_menu, int insert_at,
+                             const char *label, SEL action,
+                             int collector_index = -1);
+
+  MacStatsMonitor *_monitor;
+  int _thread_index;
+
+  int _last_level_index;
+  NSMenu *_menu;
+
+  // Pair of menu item, submenu
+  std::vector<std::pair<NSMenuItem *, NSMenu *> > _collector_items;
+  int _time_items_end = 0;
+  int _level_items_end = 0;
+};
+
+#endif

+ 246 - 0
pandatool/src/mac-stats/macStatsChartMenu.mm

@@ -0,0 +1,246 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsChartMenu.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsChartMenu.h"
+#include "macStatsChartMenuDelegate.h"
+#include "macStatsMonitor.h"
+
+/**
+ *
+ */
+MacStatsChartMenu::
+MacStatsChartMenu(MacStatsMonitor *monitor, int thread_index) :
+  _monitor(monitor),
+  _thread_index(thread_index)
+{
+  _menu = [[NSMenu alloc] init];
+  _menu.delegate = [[MacStatsChartMenuDelegate alloc] initWithMonitor:monitor threadIndex:thread_index];
+
+  if (thread_index == 0) {
+    _menu.title = @"Graphs";
+
+    // Timeline goes first.
+    make_menu_item(_menu, -1, "Timeline", @selector(handleOpenTimeline:));
+
+    // Then the piano roll (even though it's not very useful nowadays)
+    make_menu_item(_menu, -1, "Piano Roll", @selector(handleOpenPianoRoll:));
+  }
+  else {
+    make_menu_item(_menu, -1, "Open Strip Chart", @selector(handleOpenStripChart:), 0);
+    make_menu_item(_menu, -1, "Open Flame Graph", @selector(handleOpenFlameGraph:));
+  }
+
+  [_menu addItem:[NSMenuItem separatorItem]];
+  _time_items_end = 3;
+
+  // Put a separator between time items and level items.
+  [_menu addItem:[NSMenuItem separatorItem]];
+  _level_items_end = _time_items_end + 1;
+
+  // For the main thread menu, also some options relating to all graph windows.
+  if (thread_index == 0) {
+    [_menu addItem:[NSMenuItem separatorItem]];
+    make_menu_item(_menu, -1, "Close All Graphs", @selector(handleCloseAllGraphs:));
+    make_menu_item(_menu, -1, "Reopen Default Graphs", @selector(handleReopenDefaultGraphs:));
+    make_menu_item(_menu, -1, "Save Current Layout as Default", @selector(handleSaveDefaultGraphs:));
+  }
+
+  do_update();
+}
+
+/**
+ *
+ */
+MacStatsChartMenu::
+~MacStatsChartMenu() {
+  MacStatsChartMenuDelegate *delegate = (MacStatsChartMenuDelegate *)_menu.delegate;
+  [_menu release];
+  [delegate release];
+}
+
+/**
+ * Adds the menu to the end of the indicated menu bar.
+ */
+void MacStatsChartMenu::
+add_to_menu(NSMenu *menu, int position) {
+  NSMenuItem *item = [[NSMenuItem alloc] init];
+  [menu insertItem:item atIndex:position];
+  [menu setSubmenu:_menu forItem:item];
+  [item release];
+}
+
+/**
+ * Removes the menu from the menu bar.
+ */
+void MacStatsChartMenu::
+remove_from_menu(NSMenu *menu) {
+  int index = [menu indexOfItemWithSubmenu:_menu];
+  if (index >= 0) {
+    [menu removeItemAtIndex:index];
+  }
+}
+
+/**
+ * Checks to see if the menu needs to be updated (e.g.  because of new data
+ * from the client), and updates it if necessary.
+ */
+void MacStatsChartMenu::
+check_update() {
+  PStatView &view = _monitor->get_view(_thread_index);
+  if (view.get_level_index() != _last_level_index) {
+    do_update();
+  }
+}
+
+/**
+ * Unconditionally updates the menu with the latest data from the client.
+ */
+void MacStatsChartMenu::
+do_update() {
+  PStatView &view = _monitor->get_view(_thread_index);
+  _last_level_index = view.get_level_index();
+
+  const PStatClientData *client_data = _monitor->get_client_data();
+  if (_thread_index != 0) {
+    std::string thread_name = client_data->get_thread_name(_thread_index);
+    _menu.title = [NSString stringWithUTF8String:thread_name.c_str()];
+  }
+
+  if (client_data->get_num_collectors() > _collector_items.size()) {
+    _collector_items.resize(client_data->get_num_collectors(), std::make_pair(nullptr, nullptr));
+  }
+
+  // The menu item(s) for the thread's frame time goes second.
+  const PStatViewLevel *view_level = view.get_top_level();
+  if (_thread_index == 0) {
+    if (add_view(_menu, view_level, false, _time_items_end)) {
+      ++_time_items_end;
+      ++_level_items_end;
+    }
+  } else {
+    for (int c = 0; c < view_level->get_num_children(); ++c) {
+      if (add_view(_menu, view_level->get_child(c), false, _time_items_end)) {
+        ++_time_items_end;
+        ++_level_items_end;
+      }
+    }
+  }
+
+  // And then the menu item(s) for each of the level values.
+  int num_toplevel_collectors = client_data->get_num_toplevel_collectors();
+  for (int tc = 0; tc < num_toplevel_collectors; tc++) {
+    int collector = client_data->get_toplevel_collector(tc);
+    if (client_data->has_collector(collector) &&
+        client_data->get_collector_has_level(collector, _thread_index)) {
+
+      PStatView &level_view = _monitor->get_level_view(collector, _thread_index);
+      add_view(_menu, level_view.get_top_level(), true, _level_items_end);
+    }
+  }
+}
+
+/**
+ * Adds a new entry or entries to the menu for the indicated view and its
+ * children.  Returns true if an item was added, false if not.
+ */
+bool MacStatsChartMenu::
+add_view(NSMenu *parent_menu, const PStatViewLevel *view_level,
+         bool show_level, int insert_at) {
+  int collector = view_level->get_collector();
+
+  NSMenuItem *&menu_item = _collector_items[collector].first;
+  NSMenu *&menu = _collector_items[collector].second;
+
+  const PStatClientData *client_data = _monitor->get_client_data();
+
+  int num_children = view_level->get_num_children();
+  if (menu == nullptr && num_children == 0) {
+    // For a collector without children, no point in making a submenu.  We just
+    // have the item open a strip chart directly (no point in creating a flame
+    // graph if there are no children).
+    if (menu_item != nullptr) {
+      // Already exists.
+      return false;
+    }
+
+    std::string collector_name = client_data->get_collector_name(collector);
+    if (show_level) {
+      menu_item = make_menu_item(parent_menu, insert_at,
+        collector_name.c_str(), @selector(handleOpenStripChartLevel:), collector);
+    } else {
+      menu_item = make_menu_item(parent_menu, insert_at,
+        collector_name.c_str(), @selector(handleOpenStripChart:), collector);
+    }
+    return true;
+  }
+  else if (menu_item != nullptr && menu == nullptr) {
+    // Unhook the signal handler, we are creating a submenu.
+    menu_item.action = nil;
+  }
+
+  // Create a submenu.
+  bool added_item = false;
+  if (menu_item == nullptr) {
+    std::string collector_name = client_data->get_collector_name(collector);
+    menu_item = make_menu_item(parent_menu, insert_at, collector_name.c_str(), nil);
+    added_item = true;
+  }
+
+  if (menu == nullptr) {
+    menu = [[NSMenu alloc] init];
+    [parent_menu setSubmenu:menu forItem:menu_item];
+
+    if (show_level) {
+      make_menu_item(menu, -1, "Open Strip Chart",
+                     @selector(handleOpenStripChartLevel:), collector);
+    } else {
+      make_menu_item(menu, -1, "Open Strip Chart",
+                     @selector(handleOpenStripChart:), collector);
+
+      if (collector == 0) {
+        collector = -1;
+      }
+
+      make_menu_item(menu, -1, "Open Flame Graph",
+                     @selector(handleOpenFlameGraph:), collector);
+    }
+
+    [menu addItem:[NSMenuItem separatorItem]];
+    [menu release];
+  }
+
+  for (int c = 0; c < num_children; ++c) {
+    add_view(menu, view_level->get_child(c), show_level, 2 + !show_level);
+  }
+
+  return added_item;
+}
+
+/**
+ *
+ */
+NSMenuItem *MacStatsChartMenu::
+make_menu_item(NSMenu *parent_menu, int insert_at, const char *label, SEL action, int collector_index) {
+  NSMenuItem *menu_item = [[NSMenuItem alloc] init];
+  menu_item.title = [NSString stringWithUTF8String:label];
+  menu_item.target = _menu.delegate;
+  menu_item.action = action;
+  menu_item.tag = collector_index;
+  if (insert_at >= 0) {
+    [parent_menu insertItem:menu_item atIndex:insert_at];
+  } else {
+    [parent_menu addItem:menu_item];
+  }
+  [menu_item release];
+  return menu_item;
+}

+ 32 - 0
pandatool/src/mac-stats/macStatsChartMenuDelegate.h

@@ -0,0 +1,32 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsChartMenuDelegate.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSCHARTMENUDELEGATE_H
+#define MACSTATSCHARTMENUDELEGATE_H
+
+#import <Foundation/Foundation.h>
+#import <AppKit/AppKit.h>
+
+class MacStatsMonitor;
+
+@interface MacStatsChartMenuDelegate : NSObject<NSMenuDelegate> {
+  @private
+    MacStatsMonitor *_monitor;
+    int _thread_index;
+}
+
+- (id)initWithMonitor:(MacStatsMonitor *)monitor threadIndex:(int)index;
+
+@end
+
+#endif

+ 61 - 0
pandatool/src/mac-stats/macStatsChartMenuDelegate.mm

@@ -0,0 +1,61 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsChartMenuDelegate.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsChartMenuDelegate.h"
+#include "macStatsMonitor.h"
+
+@implementation MacStatsChartMenuDelegate
+
+- (id)initWithMonitor:(MacStatsMonitor *)monitor threadIndex:(int)index {
+  if (self = [super init]) {
+    _monitor = monitor;
+    _thread_index = index;
+  }
+
+  return self;
+}
+
+- (void)handleOpenTimeline:(NSMenuItem *)item {
+  _monitor->open_timeline();
+}
+
+- (void)handleOpenStripChart:(NSMenuItem *)item {
+  _monitor->open_strip_chart(_thread_index, item.tag, NO);
+}
+
+- (void)handleOpenStripChartLevel:(NSMenuItem *)item {
+  _monitor->open_strip_chart(_thread_index, item.tag, YES);
+}
+
+- (void)handleOpenFlameGraph:(NSMenuItem *)item {
+  _monitor->open_flame_graph(_thread_index, item.tag);
+}
+
+- (void)handleOpenPianoRoll:(NSMenuItem *)item {
+  _monitor->open_piano_roll(_thread_index);
+}
+
+- (void)handleCloseAllGraphs:(NSMenuItem *)item {
+  _monitor->close_all_graphs();
+}
+
+- (void)handleReopenDefaultGraphs:(NSMenuItem *)item {
+  _monitor->close_all_graphs();
+  _monitor->open_default_graphs();
+}
+
+- (void)handleSaveDefaultGraphs:(NSMenuItem *)item {
+  _monitor->save_default_graphs();
+}
+
+@end

+ 88 - 0
pandatool/src/mac-stats/macStatsFlameGraph.h

@@ -0,0 +1,88 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsFlameGraph.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSFLAMEGRAPH_H
+#define MACSTATSFLAMEGRAPH_H
+
+#include "macStatsGraph.h"
+#include "pStatFlameGraph.h"
+#include "macStatsChartMenuDelegate.h"
+
+/**
+ * A window that draws a flame chart, which shows the collectors explicitly
+ * stopping and starting, one frame at a time.
+ */
+class MacStatsFlameGraph final : public PStatFlameGraph, public MacStatsGraph {
+public:
+  MacStatsFlameGraph(MacStatsMonitor *monitor, int thread_index,
+                     int collector_index=-1);
+  virtual ~MacStatsFlameGraph();
+
+  virtual void new_collector(int collector_index);
+  virtual void new_data(int thread_index, int frame_number);
+  virtual void force_redraw();
+  virtual void changed_graph_size(int graph_xsize, int graph_ysize);
+
+  virtual void set_time_units(int unit_mask);
+  virtual void on_click_label(int collector_index);
+  virtual void on_enter_label(int collector_index);
+  virtual void on_leave_label(int collector_index);
+  virtual NSMenu *get_label_menu(int collector_index) const;
+
+protected:
+  virtual void normal_guide_bars();
+
+  void clear_region();
+  virtual void begin_draw();
+  virtual void draw_bar(int depth, int from_x, int to_x,
+                        int collector_index, int parent_index);
+  virtual void end_draw();
+  virtual void idle();
+
+  virtual bool animate(double time, double dt);
+
+  virtual bool get_window_state(int &x, int &y, int &width, int &height,
+                                bool &maximized, bool &minimized) const;
+  virtual void set_window_state(int x, int y, int width, int height,
+                                bool maximized, bool minimized);
+
+  virtual NSMenu *get_graph_menu(int mouse_x, int mouse_y) const;
+  virtual std::string get_graph_tooltip(int mouse_x, int mouse_y) const;
+  virtual DragMode consider_drag_start(int graph_x, int graph_y);
+
+  virtual void handle_button_press(int graph_x, int graph_y,
+                                       bool double_click, int button);
+  virtual void handle_button_release(int graph_x, int graph_y);
+  virtual void handle_motion(int graph_x, int graph_y);
+  virtual void handle_leave();
+  virtual void handle_draw_graph(CGContextRef ctx, NSRect rect);
+  virtual void handle_back();
+
+public:
+  void handle_toggle_average(bool state);
+
+private:
+  int pixel_to_depth(int y) const;
+  void draw_guide_bar(CGContextRef ctx, const PStatGraph::GuideBar &bar);
+  void draw_guide_labels(CGContextRef ctx);
+  void draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar);
+
+private:
+  NSToolbarItem *_total_item;
+
+  MacStatsChartMenuDelegate *_menu_delegate;
+
+  std::vector<int> _back_stack;
+};
+
+#endif

+ 871 - 0
pandatool/src/mac-stats/macStatsFlameGraph.mm

@@ -0,0 +1,871 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsFlameGraph.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsFlameGraph.h"
+#include "macStatsMonitor.h"
+#include "macStatsGraphView.h"
+#include "macStatsScaleArea.h"
+#include "pStatCollectorDef.h"
+#include "cocoa_compat.h"
+
+@interface MacStatsFlameGraphViewController : MacStatsGraphViewController
+@end
+
+static const int default_flame_graph_width = 800;
+static const int default_flame_graph_height = 250;
+
+/**
+ *
+ */
+MacStatsFlameGraph::
+MacStatsFlameGraph(MacStatsMonitor *monitor, int thread_index,
+                   int collector_index) :
+  PStatFlameGraph(monitor, thread_index, collector_index, 0, 0),
+  MacStatsGraph(monitor, [MacStatsFlameGraphViewController alloc])
+{
+  // Used for popup menus.
+  _menu_delegate = [[MacStatsChartMenuDelegate alloc] initWithMonitor:monitor threadIndex:thread_index];
+
+  // Set the initial size of the graph.
+  int height = default_flame_graph_height + _window.frame.size.height - _window.contentLayoutRect.size.height;
+  _graph_view.frame = NSMakeRect(0, 0, default_flame_graph_width, height);
+  _graph_view_controller.view.frame = NSMakeRect(0, 0, default_flame_graph_width, height);
+
+  _total_item = nil;
+  if (@available(macOS 11.0, *)) {
+    NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@""];
+    toolbar.delegate = _graph_view_controller;
+    toolbar.displayMode = NSToolbarDisplayModeIconOnly;
+    _window.toolbar = toolbar;
+    [_window setToolbarStyle:NSWindowToolbarStyleUnifiedCompact];
+
+    for (NSToolbarItem *item in toolbar.items) {
+      if ([item.itemIdentifier isEqual:@"total"]) {
+        _total_item = item;
+        break;
+      }
+    }
+    [toolbar release];
+  }
+
+  //MacStatsScaleAreaController *scale_area_controller = [[MacStatsScaleAreaController alloc] initWithGraph:this];
+  //scale_area_controller.layoutAttribute = NSLayoutAttributeRight;
+  //_scale_area = scale_area_controller.view;
+  //[_window addTitlebarAccessoryViewController:scale_area_controller];
+
+  _window.contentViewController = _graph_view_controller;
+
+  _graph_view_controller.backToolbarItemVisible = NO;
+
+  // Let's show the units on the guide bar labels.  There's room.
+  set_guide_bar_units(get_guide_bar_units() | GBU_show_units);
+
+  if (get_average_mode()) {
+    start_animation();
+  }
+
+  if (is_title_unknown()) {
+    std::string window_title = get_title_text();
+    if (!is_title_unknown()) {
+      _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+    }
+  }
+
+  if (@available(macOS 11.0, *)) {
+    std::string text = format_number(get_horizontal_scale(), get_guide_bar_units(), get_guide_bar_unit_name());
+    [_total_item setTitle:[NSString stringWithUTF8String:text.c_str()]];
+  }
+
+  [_window makeKeyAndOrderFront:nil];
+}
+
+/**
+ *
+ */
+MacStatsFlameGraph::
+~MacStatsFlameGraph() {
+  [_menu_delegate release];
+}
+
+/**
+ * Called whenever a new Collector definition is received from the client.
+ */
+void MacStatsFlameGraph::
+new_collector(int collector_index) {
+  MacStatsGraph::new_collector(collector_index);
+}
+
+/**
+ * Called as each frame's data is made available.  There is no guarantee the
+ * frames will arrive in order, or that all of them will arrive at all.  The
+ * monitor should be prepared to accept frames received out-of-order or
+ * missing.
+ */
+void MacStatsFlameGraph::
+new_data(int thread_index, int frame_number) {
+  if (is_title_unknown()) {
+    std::string window_title = get_title_text();
+    if (!is_title_unknown()) {
+      _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+    }
+  }
+
+  if (!_pause) {
+    update();
+
+    if (@available(macOS 11.0, *)) {
+      std::string text = format_number(get_horizontal_scale(), get_guide_bar_units(), get_guide_bar_unit_name());
+      [_total_item setTitle:[NSString stringWithUTF8String:text.c_str()]];
+    }
+  }
+}
+
+/**
+ * Called when it is necessary to redraw the entire graph.
+ */
+void MacStatsFlameGraph::
+force_redraw() {
+  if (_ctx) {
+    PStatFlameGraph::force_redraw();
+  }
+}
+
+/**
+ * Called when the user has resized the window, forcing a resize of the graph.
+ */
+void MacStatsFlameGraph::
+changed_graph_size(int graph_xsize, int graph_ysize) {
+  PStatFlameGraph::changed_size(graph_xsize, graph_ysize);
+}
+
+/**
+ * Called when the user selects a new time units from the monitor pulldown
+ * menu, this should adjust the units for the graph to the indicated mask if
+ * it is a time-based graph.
+ */
+void MacStatsFlameGraph::
+set_time_units(int unit_mask) {
+  int old_unit_mask = get_guide_bar_units();
+  if ((old_unit_mask & (GBU_hz | GBU_ms)) != 0) {
+    unit_mask = unit_mask & (GBU_hz | GBU_ms);
+    unit_mask |= (old_unit_mask & GBU_show_units);
+    set_guide_bar_units(unit_mask);
+
+    if (@available(macOS 11.0, *)) {
+      std::string text = format_number(get_horizontal_scale(), get_guide_bar_units(), get_guide_bar_unit_name());
+      [_total_item setTitle:[NSString stringWithUTF8String:text.c_str()]];
+    }
+
+    //_scale_area.needsDisplay = YES;
+  }
+}
+
+/**
+ * Called when the user single-clicks on a label.
+ */
+void MacStatsFlameGraph::
+on_click_label(int collector_index) {
+  int current = get_collector_index();
+  if (collector_index != current) {
+    if (_back_stack.empty()) {
+      _graph_view_controller.backToolbarItemVisible = YES;
+    }
+    _back_stack.push_back(current);
+    set_collector_index(collector_index);
+
+    std::string window_title = get_title_text();
+    if (!is_title_unknown()) {
+      _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+    }
+  }
+}
+
+/**
+ * Called when the user hovers the mouse over a label.
+ */
+void MacStatsFlameGraph::
+on_enter_label(int collector_index) {
+  if (collector_index != _highlighted_index) {
+    _highlighted_index = collector_index;
+
+    if (!get_average_mode()) {
+      PStatFlameGraph::force_redraw();
+    }
+  }
+}
+
+/**
+ * Called when the user's mouse cursor leaves a label.
+ */
+void MacStatsFlameGraph::
+on_leave_label(int collector_index) {
+  if (collector_index == _highlighted_index && collector_index != -1) {
+    _highlighted_index = -1;
+
+    if (!get_average_mode()) {
+      PStatFlameGraph::force_redraw();
+    }
+  }
+}
+
+/**
+ * Called when the mouse right-clicks on a label, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsFlameGraph::
+get_label_menu(int collector_index) const {
+  NSMenu *menu = [[[NSMenu alloc] init] autorelease];
+
+  std::string label = get_label_tooltip(collector_index);
+  if (!label.empty()) {
+    if (@available(macOS 14.0, *)) {
+      [menu addItem:[NSMenuItem sectionHeaderWithTitle:[NSString stringWithUTF8String:label.c_str()]]];
+    } else {
+      NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:label.c_str()] action:nil keyEquivalent:@""];
+      item.enabled = NO;
+      [menu addItem:item];
+      [item release];
+    }
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Set as Focus" action:@selector(handleSetAsFocus:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    item.enabled = (collector_index != 0 || get_collector_index() != 0);
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Strip Chart" action:@selector(handleOpenStripChart:) keyEquivalent:@""];
+    item.target = _menu_delegate;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Flame Graph" action:@selector(handleOpenFlameGraph:) keyEquivalent:@""];
+    item.target = _menu_delegate;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  [menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Change Color\u2026" action:@selector(handleChangeColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Reset Color" action:@selector(handleResetColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  return menu;
+}
+
+/**
+ * Calls update_guide_bars with parameters suitable to this kind of graph.
+ */
+void MacStatsFlameGraph::
+normal_guide_bars() {
+  // We want vaguely 100 pixels between guide bars.
+  int num_bars = get_xsize() / 100;
+
+  _guide_bars.clear();
+
+  double dist = get_horizontal_scale() / num_bars;
+
+  for (int i = 1; i < num_bars; ++i) {
+    _guide_bars.push_back(make_guide_bar(i * dist));
+  }
+
+  _guide_bars_changed = true;
+
+  //nassertv_always(_scale_area != nullptr);
+  //_scale_area.needsDisplay = YES;
+}
+
+/**
+ * Erases the chart area.
+ */
+void MacStatsFlameGraph::
+clear_region() {
+  if (_ctx) {
+    CGContextSetFillColorWithColor(_ctx, _background_color);
+    CGContextFillRect(_ctx, CGRectMake(0, 0, get_xsize(), get_ysize()));
+  }
+}
+
+/**
+ * Erases the chart area in preparation for drawing a bunch of bars.
+ */
+void MacStatsFlameGraph::
+begin_draw() {
+  if (!_ctx) {
+    return;
+  }
+
+  clear_region();
+
+  // isFlipped is true in the NSView, so flip the text again
+  CGContextSetTextMatrix(_ctx, CGAffineTransformMakeScale(1, -1));
+
+  // Draw in the guide bars.
+  int num_guide_bars = get_num_guide_bars();
+  for (int i = 0; i < num_guide_bars; i++) {
+    const GuideBar &bar = get_guide_bar(i);
+    draw_guide_bar(_ctx, bar);
+    draw_guide_label(_ctx, bar);
+  }
+}
+
+/**
+ * Should be overridden by the user class.  Should draw a single bar at the
+ * indicated location.
+ */
+void MacStatsFlameGraph::
+draw_bar(int depth, int from_x, int to_x, int collector_index, int parent_index) {
+  double bottom = get_ysize() - depth * 4.000 * 5;
+  double top = bottom - 4.000 * 5;
+
+  top += 1;
+
+  MacStatsMonitor *monitor = MacStatsGraph::_monitor;
+
+  bool is_highlighted = collector_index == _highlighted_index;
+  CGContextSetFillColorWithColor(_ctx,
+    monitor->get_collector_color(collector_index, is_highlighted));
+
+  if (to_x < from_x + 3) {
+    // It's just a tiny sliver.  This is a more reliable way to draw it.
+    CGRect rect = CGRectMake(from_x, top, to_x - from_x, bottom - top);
+    CGContextFillRect(_ctx, rect);
+  }
+  else {
+    double radius = std::min((double)4.000, (to_x - from_x) / 2.0);
+    CGContextBeginPath(_ctx);
+    CGContextAddArc(_ctx, to_x - radius, top + radius, radius, -0.5 * M_PI, 0.0, NO);
+    CGContextAddArc(_ctx, to_x - radius, bottom - radius, radius, 0.0, 0.5 * M_PI, NO);
+    CGContextAddArc(_ctx, from_x + radius, bottom - radius, radius, 0.5 * M_PI, M_PI, NO);
+    CGContextAddArc(_ctx, from_x + radius, top + radius, radius, M_PI, 1.5 * M_PI, NO);
+    CGContextClosePath(_ctx);
+    CGContextFillPath(_ctx);
+
+    if ((to_x - from_x) >= 4.000 * 4) {
+      // Only bother drawing the text if we've got some space to draw on.
+      int left = std::max(from_x, 0) + 4.000 / 2;
+      int right = std::min(to_x, get_xsize()) - 4.000 / 2;
+
+      const PStatClientData *client_data = monitor->get_client_data();
+      const PStatCollectorDef &def = client_data->get_collector_def(collector_index);
+
+      const CFStringRef keys[] = {
+        (__bridge CFStringRef)NSForegroundColorAttributeName,
+        (__bridge CFStringRef)NSFontAttributeName,
+      };
+      const void *values[] = {
+        monitor->get_collector_text_color(collector_index, is_highlighted),
+        [NSFont systemFontOfSize:0.0],
+      };
+      CFDictionaryRef attribs = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+
+      CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, def._name.c_str(), kCFStringEncodingUTF8);
+      CFAttributedStringRef astr = CFAttributedStringCreate(kCFAllocatorDefault, str, attribs);
+
+      CTLineRef line = CTLineCreateWithAttributedString(astr);
+      CGRect bounds = CTLineGetImageBounds(line, _ctx);
+      CFRelease(astr);
+      CFRelease(str);
+
+      if (bounds.size.width < right - left) {
+        // We have room for more.  Show the collector's actual parent, if it's
+        // different than the block it's shown above.
+        if (def._parent_index > 0 && def._parent_index != parent_index) {
+          const PStatCollectorDef &parent_def = client_data->get_collector_def(def._parent_index);
+          std::string long_name = parent_def._name + ":" + def._name;
+
+          CFStringRef long_str = CFStringCreateWithCString(kCFAllocatorDefault, long_name.c_str(), kCFStringEncodingUTF8);
+          CFAttributedStringRef long_astr = CFAttributedStringCreate(kCFAllocatorDefault, long_str, attribs);
+
+          CTLineRef long_line = CTLineCreateWithAttributedString((CFAttributedStringRef)long_astr);
+          CGRect long_bounds = CTLineGetImageBounds(long_line, _ctx);
+
+          if (long_bounds.size.width < right - left) {
+            CFRelease(line);
+            line = long_line;
+            bounds = long_bounds;
+          } else {
+            CFRelease(long_line);
+          }
+          CFRelease(long_astr);
+          CFRelease(long_str);
+        }
+      }
+      else {
+        static CFStringRef token_str = CFSTR("\u2026");
+        CFAttributedStringRef token_astr = CFAttributedStringCreate(kCFAllocatorDefault, token_str, attribs);
+        CTLineRef token_line = CTLineCreateWithAttributedString(token_astr);
+        CTLineRef trunc_line = CTLineCreateTruncatedLine(line, right - left, kCTLineTruncationEnd, token_line);
+        CFRelease(line);
+        CFRelease(token_astr);
+        CFRelease(token_line);
+        line = trunc_line;
+      }
+
+      // Center the text vertically in the bar.
+      if (line != nullptr) {
+        CGContextSetTextPosition(_ctx, left, top + (bottom - top + bounds.size.height) / 2);
+        CTLineDraw(line, _ctx);
+        CFRelease(line);
+      }
+      CFRelease(attribs);
+    }
+  }
+}
+
+/**
+ * Called after all the bars have been drawn, this triggers a refresh event to
+ * draw it to the window.
+ */
+void MacStatsFlameGraph::
+end_draw() {
+  _graph_view.needsDisplay = YES;
+}
+
+/**
+ * Called at the end of the draw cycle.
+ */
+void MacStatsFlameGraph::
+idle() {
+}
+
+/**
+ * Overridden by a derived class to implement an animation.  If it returns
+ * false, the animation timer is stopped.
+ */
+bool MacStatsFlameGraph::
+animate(double time, double dt) {
+  return PStatFlameGraph::animate(time, dt);
+}
+
+/**
+ * Returns the current window dimensions.
+ */
+bool MacStatsFlameGraph::
+get_window_state(int &x, int &y, int &width, int &height,
+                 bool &maximized, bool &minimized) const {
+  MacStatsGraph::get_window_state(x, y, width, height, maximized, minimized);
+  return true;
+}
+
+/**
+ * Called to restore the graph window to its previous dimensions.
+ */
+void MacStatsFlameGraph::
+set_window_state(int x, int y, int width, int height,
+                 bool maximized, bool minimized) {
+  MacStatsGraph::set_window_state(x, y, width, height, maximized, minimized);
+}
+
+/**
+ * Called when the mouse right-clicks on the graph, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsFlameGraph::
+get_graph_menu(int mouse_x, int mouse_y) const {
+  int collector_index = get_bar_collector(pixel_to_depth(mouse_y), mouse_x);
+  if (collector_index != -1) {
+    return get_label_menu(collector_index);
+  }
+  return nil;
+}
+
+/**
+ * Called when the mouse hovers over the graph, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsFlameGraph::
+get_graph_tooltip(int mouse_x, int mouse_y) const {
+  return get_bar_tooltip(pixel_to_depth(mouse_y), mouse_x);
+}
+
+/**
+ * Based on the mouse position within the window's client area, look for
+ * draggable things the mouse might be hovering over and return the
+ * apprioprate DragMode enum or DM_none if nothing is indicated.
+ */
+MacStatsGraph::DragMode MacStatsFlameGraph::
+consider_drag_start(int graph_x, int graph_y) {
+  if (graph_y >= 0 && graph_y < get_ysize()) {
+    if (graph_x >= 0 && graph_x < get_xsize()) {
+      // See if the mouse is over a user-defined guide bar.
+      int x = graph_x;
+      double from_height = pixel_to_height(x - 2);
+      double to_height = pixel_to_height(x + 2);
+      _drag_guide_bar = find_user_guide_bar(from_height, to_height);
+      if (_drag_guide_bar >= 0) {
+        return DM_guide_bar;
+      }
+
+    } else {
+      // The mouse is left or right of the graph; maybe create a new guide
+      // bar.
+      return DM_new_guide_bar;
+    }
+  }
+
+  return DM_none;
+}
+
+/**
+ * Called when the mouse button is depressed within the graph window.
+ */
+void MacStatsFlameGraph::
+handle_button_press(int graph_x, int graph_y, bool double_click, int button) {
+  if (graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    int depth = pixel_to_depth(graph_y);
+    int collector_index = get_bar_collector(depth, graph_x);
+    if (double_click && button == 0) {
+      // Double-clicking on a color bar in the graph will zoom the graph into
+      // that collector.
+      if (collector_index >= 0) {
+        on_click_label(collector_index);
+      } else {
+        if (!_back_stack.empty()) {
+          _back_stack.clear();
+          _graph_view_controller.backToolbarItemVisible = NO;
+        }
+        set_collector_index(-1);
+        std::string window_title = get_title_text();
+        if (!is_title_unknown()) {
+          _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+        }
+      }
+      return;
+    }
+  }
+
+  if (_potential_drag_mode == DM_none) {
+    set_drag_mode(DM_scale);
+    _drag_scale_start = pixel_to_height(graph_x);
+    // SetCapture(_graph_window);
+    return;
+  }
+  else if (_potential_drag_mode == DM_guide_bar && _drag_guide_bar >= 0) {
+    set_drag_mode(DM_guide_bar);
+    _drag_start_x = graph_x;
+    // SetCapture(_graph_window);
+    return;
+  }
+
+  return MacStatsGraph::handle_button_press(graph_x, graph_y,
+                                            double_click, button);
+}
+
+/**
+ * Called when the mouse button is released within the graph window.
+ */
+void MacStatsFlameGraph::
+handle_button_release(int graph_x, int graph_y) {
+  if (_drag_mode == DM_scale) {
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+  else if (_drag_mode == DM_guide_bar) {
+    if (graph_x < 0 || graph_x >= get_xsize()) {
+      remove_user_guide_bar(_drag_guide_bar);
+    } else {
+      move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_x));
+    }
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+
+  return MacStatsGraph::handle_button_release(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsFlameGraph::
+handle_motion(int graph_x, int graph_y) {
+  if (_drag_mode == DM_none && _potential_drag_mode == DM_none &&
+      graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    // When the mouse is over a color bar, highlight it.
+    int depth = pixel_to_depth(graph_y);
+    int collector_index = get_bar_collector(depth, graph_x);
+    on_enter_label(collector_index);
+  }
+  else {
+    // If the mouse is in some drag mode, stop highlighting.
+    _label_stack.highlight_label(-1);
+    on_leave_label(_highlighted_index);
+  }
+
+  if (_drag_mode == DM_new_guide_bar) {
+    // We haven't created the new guide bar yet; we won't until the mouse
+    // comes within the graph's region.
+    if (graph_x >= 0 && graph_x < get_xsize()) {
+      set_drag_mode(DM_guide_bar);
+      _drag_guide_bar = add_user_guide_bar(pixel_to_height(graph_x));
+      return;
+    }
+  }
+  else if (_drag_mode == DM_guide_bar) {
+    move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_x));
+    return;
+  }
+
+  return MacStatsGraph::handle_motion(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse has left the graph window.
+ */
+void MacStatsFlameGraph::
+handle_leave() {
+  _label_stack.highlight_label(-1);
+  on_leave_label(_highlighted_index);
+  return;
+}
+
+/**
+ * Fills in the graph window.
+ */
+void MacStatsFlameGraph::
+handle_draw_graph(CGContextRef ctx, NSRect rect) {
+  MacStatsGraph::handle_draw_graph(ctx, rect);
+
+  CGContextSetTextMatrix(ctx, CGAffineTransformMakeScale(1, -1));
+
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (int i = 0; i < num_user_guide_bars; i++) {
+    const GuideBar &bar = get_user_guide_bar(i);
+    draw_guide_bar(ctx, bar);
+    draw_guide_label(ctx, bar);
+  }
+}
+
+/**
+ * Called when the mouse clicks the back button in the toolbar.
+ */
+void MacStatsFlameGraph::
+handle_back() {
+  if (!_back_stack.empty()) {
+    int collector_index = _back_stack.back();
+    _back_stack.pop_back();
+    set_collector_index(collector_index);
+
+    if (_back_stack.empty()) {
+      _graph_view_controller.backToolbarItemVisible = NO;
+    }
+
+    std::string window_title = get_title_text();
+    if (!is_title_unknown()) {
+      _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+    }
+  }
+}
+
+/**
+ * Called when the mouse toggles the "Average" checkbox in the toolbar.
+ */
+void MacStatsFlameGraph::
+handle_toggle_average(bool state) {
+  set_average_mode(state);
+  if (state) {
+    start_animation();
+  }
+}
+
+
+/**
+ * Converts a pixel to a depth index.
+ */
+int MacStatsFlameGraph::
+pixel_to_depth(int y) const {
+  return (get_ysize() - 1 - y) / (4.000 * 5);
+}
+
+/**
+ * Draws the line for the indicated guide bar on the graph.
+ */
+void MacStatsFlameGraph::
+draw_guide_bar(CGContextRef ctx, const PStatGraph::GuideBar &bar) {
+  int x = height_to_pixel(bar._height);
+
+  if (x > 0 && x < get_xsize() - 1) {
+    // Only draw it if it's not too close to the top.
+    CGContextSetStrokeColorWithColor(ctx, [NSColor gridColor].CGColor);
+    /*switch (bar._style) {
+    case GBS_target:
+      CGContextSetRGBStrokeColor(ctx, rgb_light_gray[0], rgb_light_gray[1], rgb_light_gray[2], 1.0);
+      break;
+
+    case GBS_user:
+      CGContextSetRGBStrokeColor(ctx, rgb_user_guide_bar[0], rgb_user_guide_bar[1], rgb_user_guide_bar[2], 1.0);
+      break;
+
+    default:
+      CGContextSetRGBStrokeColor(ctx, rgb_dark_gray[0], rgb_dark_gray[1], rgb_dark_gray[2], 1.0);
+      break;
+    }*/
+    CGContextBeginPath(ctx);
+    CGContextMoveToPoint(ctx, x, 0);
+    CGContextAddLineToPoint(ctx, x, get_ysize());
+    CGContextStrokePath(ctx);
+  }
+}
+
+/**
+ * This is called during the servicing of the draw event.
+ */
+void MacStatsFlameGraph::
+draw_guide_labels(CGContextRef ctx) {
+  int num_guide_bars = get_num_guide_bars();
+  for (int i = 0; i < num_guide_bars; i++) {
+    draw_guide_label(ctx, get_guide_bar(i));
+  }
+
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (int i = 0; i < num_user_guide_bars; i++) {
+    draw_guide_label(ctx, get_user_guide_bar(i));
+  }
+}
+
+/**
+ * Draws the text for the indicated guide bar label at the top of the graph.
+ */
+void MacStatsFlameGraph::
+draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar) {
+  NSColor *color;
+  color = [NSColor tertiaryLabelColor];
+  /*switch (bar._style) {
+  case GBS_target:
+    color = [NSColor colorWithDeviceRed:rgb_light_gray[0] green:rgb_light_gray[1] blue:rgb_light_gray[2] alpha:1.0];
+    break;
+
+  case GBS_user:
+    color = [NSColor colorWithDeviceRed:rgb_user_guide_bar[0] green:rgb_user_guide_bar[1] blue:rgb_user_guide_bar[2] alpha:1.0];
+    break;
+
+  default:
+    color = [NSColor colorWithDeviceRed:rgb_dark_gray[0] green:rgb_dark_gray[1] blue:rgb_dark_gray[2] alpha:1.0];
+    break;
+  }*/
+
+  int x = height_to_pixel(bar._height);
+  const std::string &label = bar._label;
+
+  const CFStringRef keys[] = {
+    (__bridge CFStringRef)NSForegroundColorAttributeName,
+    (__bridge CFStringRef)NSFontAttributeName,
+  };
+  const void *values[] = {
+    color,
+    [NSFont systemFontOfSize:0.0],
+  };
+  CFDictionaryRef attribs = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+
+  CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, label.c_str(), kCFStringEncodingUTF8);
+  CFAttributedStringRef astr = CFAttributedStringCreate(kCFAllocatorDefault, str, attribs);
+  CFRelease(attribs);
+  CFRelease(str);
+
+  CTLineRef line = CTLineCreateWithAttributedString(astr);
+  CFRelease(astr);
+  CGRect bounds = CTLineGetImageBounds(line, ctx);
+  int width = bounds.size.width;
+
+  if (bar._style != GBS_user) {
+    double from_height = pixel_to_height(x - width);
+    double to_height = pixel_to_height(x + width);
+    if (find_user_guide_bar(from_height, to_height) >= 0) {
+      // Omit the label: there's a user-defined guide bar in the same space.
+      CFRelease(line);
+      return;
+    }
+  }
+
+  if (x >= 0 && x < get_xsize()) {
+    int y = bounds.size.height;
+    if (@available(macOS 11.0, *)) {
+      // Account for underlap of title bar
+      y += _window.frame.size.height - _window.contentLayoutRect.size.height;
+    }
+    CGContextSetTextPosition(ctx, x + 6, y + 6);
+    CTLineDraw(line, ctx);
+  }
+
+  CFRelease(line);
+}
+
+@implementation MacStatsFlameGraphViewController
+
+- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar {
+  return @[@"back", @"average", @"total"];
+}
+
+- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar {
+  return @[@"average", @"total"];
+}
+
+- (NSToolbarItem *) toolbar:(NSToolbar *)toolbar
+      itemForItemIdentifier:(NSString *)ident
+  willBeInsertedIntoToolbar:(BOOL)flag {
+
+  if (@available(macOS 11.0, *)) {
+    if ([ident isEqual:@"average"]) {
+      NSButton *button = [NSButton buttonWithTitle:@"Average" target:self action:@selector(handleToggleAverage:)];
+      button.image = [NSImage imageWithSystemSymbolName:@"sum" accessibilityDescription:@""];
+      button.bezelStyle = NSBezelStyleTexturedRounded;
+      button.buttonType = NSButtonTypePushOnPushOff;
+      button.bordered = YES;
+      button.toolTip = @"Average";
+      button.state = NSOffState;
+      NSToolbarItem *item = [[[NSToolbarItem alloc] initWithItemIdentifier:ident] autorelease];
+      item.label = @"Average";
+      item.view = button;
+      return item;
+    }
+    if ([ident isEqual:@"total"]) {
+      NSToolbarItem *item = [[[NSToolbarItem alloc] initWithItemIdentifier:ident] autorelease];
+      item.label = @"Total";
+      item.enabled = NO;
+      [item setBordered:YES];
+      return item;
+    }
+  }
+
+  return [super toolbar:toolbar itemForItemIdentifier:ident willBeInsertedIntoToolbar:flag];
+}
+
+- (void)handleToggleAverage:(NSButton *)button {
+  MacStatsFlameGraph *graph = (MacStatsFlameGraph *)_graph;
+  graph->handle_toggle_average(button.state == NSOnState);
+}
+
+@end

+ 139 - 0
pandatool/src/mac-stats/macStatsGraph.h

@@ -0,0 +1,139 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsGraph.h
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#ifndef MACSTATSGRAPH_H
+#define MACSTATSGRAPH_H
+
+#include "pandatoolbase.h"
+#include "macStatsGraphViewController.h"
+#include "macStatsLabelStack.h"
+#include "pmap.h"
+#include "luse.h"
+
+class MacStatsMonitor;
+
+/**
+ * This is just an abstract base class to provide a common pointer type for
+ * the various kinds of graphs that may be created for a MacStatsMonitor.
+ */
+class MacStatsGraph {
+public:
+  // What is the user adjusting by dragging the mouse in a window?
+  enum DragMode {
+    DM_none,
+    DM_scale,
+    DM_guide_bar,
+    DM_new_guide_bar,
+    DM_sizing,
+    DM_pan,
+  };
+
+public:
+  MacStatsGraph(MacStatsMonitor *monitor, MacStatsGraphViewController *controller);
+  virtual ~MacStatsGraph();
+
+  void close();
+
+  MacStatsMonitor *get_monitor() { return _monitor; }
+  NSSplitView *get_split_view() { return _split_view; }
+
+  virtual void new_collector(int collector_index);
+  virtual void new_data(int thread_index, int frame_number);
+  virtual void force_redraw()=0;
+  virtual void changed_graph_size(int graph_xsize, int graph_ysize);
+
+  virtual void set_time_units(int unit_mask);
+  virtual void set_scroll_speed(double scroll_speed);
+  void set_pause(bool pause);
+
+  void user_guide_bars_changed();
+  virtual void on_click_label(int collector_index);
+  virtual void on_enter_label(int collector_index);
+  virtual void on_leave_label(int collector_index);
+  virtual NSMenu *get_label_menu(int collector_index) const;
+  virtual std::string get_label_tooltip(int collector_index) const;
+
+  void reset_collector_color(int collector_index);
+
+protected:
+  void start_animation();
+  virtual bool animate(double time, double dt);
+
+  void get_window_state(int &x, int &y, int &width, int &height,
+                        bool &maximized, bool &minimized) const;
+  void set_window_state(int x, int y, int width, int height,
+                        bool maximized, bool minimized);
+
+public:
+  // These must be public, because we can't declare Objective-C friends here.
+  virtual NSMenu *get_graph_menu(int mouse_x, int mouse_y) const;
+  virtual std::string get_graph_tooltip(int mouse_x, int mouse_y) const;
+  virtual DragMode consider_drag_start(int graph_x, int graph_y);
+  virtual void set_drag_mode(DragMode drag_mode);
+
+  virtual bool handle_key(int graph_x, int graph_y, bool pressed,
+                          UniChar c, unsigned short key_code);
+  virtual void handle_button_press(int graph_x, int graph_y,
+                                   bool double_click, int button);
+  virtual void handle_button_release(int graph_x, int graph_y);
+  virtual void handle_motion(int graph_x, int graph_y);
+  virtual void handle_leave();
+  virtual void handle_scroll();
+  virtual void handle_wheel(int graph_x, int graph_y, double dx, double dy);
+  virtual void handle_magnify(int graph_x, int graph_y, double scale);
+  virtual void handle_draw_graph(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_graph_overhang(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_scale_area(CGContextRef ctx, NSRect rect);
+  virtual void handle_back();
+  virtual void handle_timer();
+
+protected:
+  CGColorRef _background_color;
+
+  MacStatsMonitor *_monitor;
+  NSWindow *_window;
+  NSView *_graph_view;
+  MacStatsGraphViewController *_graph_view_controller;
+  NSView *_scale_area = nullptr;
+  NSSplitView *_split_view = nullptr;
+  NSScrollView *_sidebar_view = nullptr;
+  MacStatsLabelStack _label_stack;
+
+  CGContextRef _ctx = nullptr;
+  int _bitmap_xsize, _bitmap_ysize;
+
+  DragMode _drag_mode;
+  DragMode _potential_drag_mode;
+  int _drag_start_x, _drag_start_y;
+  double _drag_scale_start;
+  int _drag_guide_bar;
+
+  int _highlighted_index = -1;
+
+  bool _pause;
+
+  NSTimer *_animation_timer = nil;
+  double _time = 0.0;
+
+  static const CGFloat rgb_white[4];
+  static const CGFloat rgb_light_gray[4];
+  static const CGFloat rgb_dark_gray[4];
+  static const CGFloat rgb_black[4];
+  static const CGFloat rgb_user_guide_bar[4];
+
+private:
+  void setup_bitmap(int xsize, int ysize, double scale);
+  void release_bitmap();
+};
+
+#endif

+ 491 - 0
pandatool/src/mac-stats/macStatsGraph.mm

@@ -0,0 +1,491 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsGraph.mm
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#include "macStatsGraph.h"
+#include "macStatsGraphView.h"
+#include "macStatsMonitor.h"
+#include "macStatsLabelStack.h"
+
+const CGFloat MacStatsGraph::rgb_light_gray[4] = {
+  0x9a / (CGFloat)0xff, 0x9a / (CGFloat)0xff, 0x9a / (CGFloat)0xff, 1.0,
+};
+const CGFloat MacStatsGraph::rgb_dark_gray[4] = {
+  0xb0 / (CGFloat)0xff, 0xb0 / (CGFloat)0xff, 0xb0 / (CGFloat)0xff, 1.0,
+};
+const CGFloat MacStatsGraph::rgb_user_guide_bar[4] = {
+  0x82 / (CGFloat)0xff, 0x96 / (CGFloat)0xff, 0xff / (CGFloat)0xff, 1.0,
+};
+
+static const NSInteger style_mask = NSWindowStyleMaskTitled
+                                  | NSWindowStyleMaskClosable
+                                  | NSWindowStyleMaskMiniaturizable
+                                  | NSWindowStyleMaskResizable;
+
+/**
+ *
+ */
+MacStatsGraph::
+MacStatsGraph(MacStatsMonitor *monitor, MacStatsGraphViewController *controller) :
+  _monitor(monitor)
+{
+  _background_color = CGColorCreateGenericRGB(0.0, 0.0, 0.0, 0.0);
+
+  _drag_mode = DM_none;
+  _potential_drag_mode = DM_none;
+  _drag_scale_start = 0.0f;
+
+  _pause = false;
+
+  NSInteger this_style_mask = style_mask;
+  if (@available(macOS 11.0, *)) {
+    this_style_mask |= NSWindowStyleMaskFullSizeContentView;
+  }
+
+  _window = [NSWindow alloc];
+  [_window initWithContentRect:NSMakeRect(100, 500, 500, 150)
+                     styleMask:this_style_mask
+                       backing:NSBackingStoreBuffered
+                         defer:NO];
+  _window.releasedWhenClosed = NO;
+  _window.titlebarAppearsTransparent = NO;
+  _window.excludedFromWindowsMenu = NO;
+
+  _window.contentMinSize = NSMakeSize(68, _window.contentView.frame.size.height - _window.contentLayoutRect.size.height);
+
+  _graph_view_controller = [controller initWithGraph:this];
+  _graph_view = ((MacStatsGraphViewController *)_graph_view_controller).graphView;
+
+  // Get notified when the window closes.
+  NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+  [center addObserver:_graph_view_controller
+             selector:@selector(windowWillClose:)
+                 name:NSWindowWillCloseNotification
+               object:_window];
+}
+
+/**
+ *
+ */
+MacStatsGraph::
+~MacStatsGraph() {
+  if (_animation_timer != nil) {
+    [_animation_timer invalidate];
+    [_animation_timer release];
+    _animation_timer = nil;
+  }
+
+  _monitor = nullptr;
+  release_bitmap();
+
+  _label_stack.clear_labels();
+
+  [_graph_view_controller release];
+  [_window release];
+
+  CGColorRelease(_background_color);
+}
+
+/**
+ *
+ */
+void MacStatsGraph::
+close() {
+  [_window close];
+}
+
+/**
+ * Called whenever a new Collector definition is received from the client.
+ */
+void MacStatsGraph::
+new_collector(int new_collector) {
+}
+
+/**
+ * Called whenever new data arrives.
+ */
+void MacStatsGraph::
+new_data(int thread_index, int frame_number) {
+}
+
+/**
+ * Called when the user has resized the window, forcing a resize of the graph.
+ */
+void MacStatsGraph::
+changed_graph_size(int graph_xsize, int graph_ysize) {
+}
+
+/**
+ * Called when the user selects a new time units from the monitor pulldown
+ * menu, this should adjust the units for the graph to the indicated mask if
+ * it is a time-based graph.
+ */
+void MacStatsGraph::
+set_time_units(int unit_mask) {
+}
+
+/**
+ * Called when the user selects a new scroll speed from the monitor pulldown
+ * menu, this should adjust the speed for the graph to the indicated value.
+ */
+void MacStatsGraph::
+set_scroll_speed(double scroll_speed) {
+}
+
+/**
+ * Changes the pause flag for the graph.  When this flag is true, the graph
+ * does not update in response to new data.
+ */
+void MacStatsGraph::
+set_pause(bool pause) {
+  _pause = pause;
+}
+
+/**
+ * Called when the user guide bars have been changed.
+ */
+void MacStatsGraph::
+user_guide_bars_changed() {
+  if (_scale_area != nullptr) {
+    _scale_area.needsDisplay = YES;
+  }
+  _graph_view.needsDisplay = YES;
+}
+
+/**
+ * Called when the user single-clicks on a label.
+ */
+void MacStatsGraph::
+on_click_label(int collector_index) {
+}
+
+/**
+ * Called when the user hovers the mouse over a label.
+ */
+void MacStatsGraph::
+on_enter_label(int collector_index) {
+  if (collector_index != _highlighted_index) {
+    _highlighted_index = collector_index;
+    force_redraw();
+  }
+}
+
+/**
+ * Called when the user's mouse cursor leaves a label.
+ */
+void MacStatsGraph::
+on_leave_label(int collector_index) {
+  if (collector_index == _highlighted_index && collector_index != -1) {
+    _highlighted_index = -1;
+    force_redraw();
+  }
+}
+
+/**
+ * Called when the mouse right-clicks on a label, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsGraph::
+get_label_menu(int collector_index) const {
+  return nil;
+}
+
+/**
+ * Called when the mouse hovers over a label, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsGraph::
+get_label_tooltip(int collector_index) const {
+  return std::string();
+}
+
+/**
+ * Turns on the animation timer, if it hasn't already been turned on.
+ */
+void MacStatsGraph::
+start_animation() {
+  if (_animation_timer != nil) {
+    return;
+  }
+
+  _time = 0.0;
+  _animation_timer = [NSTimer scheduledTimerWithTimeInterval:1 / 60.0 target:_graph_view selector:@selector(handleTimer:) userInfo:nil repeats:YES];
+  [_animation_timer retain];
+}
+
+/**
+ * Overridden by a derived class to implement an animation.  If it returns
+ * false, the animation timer is stopped.
+ */
+bool MacStatsGraph::
+animate(double time, double dt) {
+  return false;
+}
+
+/**
+ * Returns the current window dimensions.
+ */
+void MacStatsGraph::
+get_window_state(int &x, int &y, int &width, int &height,
+                 bool &maximized, bool &minimized) const {
+
+  NSRect screen_frame = _window.screen.visibleFrame;
+
+  NSRect frame = _window.frame;
+  x = frame.origin.x - screen_frame.origin.x;
+  y = (screen_frame.origin.y + screen_frame.size.height) - (frame.origin.y + frame.size.height);
+  width = frame.size.width;
+  height = frame.size.height;
+  maximized = _window.zoomed;
+  minimized = _window.miniaturized;
+}
+
+/**
+ * Called to restore the graph window to its previous dimensions.
+ */
+void MacStatsGraph::
+set_window_state(int x, int y, int width, int height,
+                 bool maximized, bool minimized) {
+
+  NSRect screen_frame = _window.screen.visibleFrame;
+
+  NSRect frame;
+  frame.origin.x = screen_frame.origin.x + x;
+  frame.origin.y = (screen_frame.origin.y + screen_frame.size.height) - (y + height);
+  frame.size.width = width;
+  frame.size.height = height;
+  [_window setFrame:frame display:NO];
+
+  if (maximized != _window.zoomed) {
+    [_window zoom:_window];
+  }
+
+  if (minimized != _window.miniaturized) {
+    if (minimized) {
+      [_window miniaturize:_window];
+    } else {
+      [_window deminiaturize:_window];
+    }
+  }
+}
+
+/**
+ * Called when the given collector has changed colors.
+ */
+void MacStatsGraph::
+reset_collector_color(int collector_index) {
+  force_redraw();
+  _label_stack.update_label_color(collector_index);
+}
+
+/**
+ * Called when the mouse right-clicks on the graph, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsGraph::
+get_graph_menu(int mouse_x, int mouse_y) const {
+  return nil;
+}
+
+/**
+ * Called when the mouse hovers over the graph, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsGraph::
+get_graph_tooltip(int mouse_x, int mouse_y) const {
+  return std::string();
+}
+
+/**
+ * Based on the mouse position within the graph window, look for draggable
+ * things the mouse might be hovering over and return the appropriate DragMode
+ * enum or DM_none if nothing is indicated.
+ */
+MacStatsGraph::DragMode MacStatsGraph::
+consider_drag_start(int graph_x, int graph_y) {
+  return DM_none;
+}
+
+/**
+ * This should be called whenever the drag mode needs to change state.  It
+ * provides hooks for a derived class to do something special.
+ */
+void MacStatsGraph::
+set_drag_mode(MacStatsGraph::DragMode drag_mode) {
+  _drag_mode = drag_mode;
+}
+
+/**
+ *
+ */
+bool MacStatsGraph::
+handle_key(int graph_x, int graph_y, bool pressed, UniChar c, unsigned short key_code) {
+  return false;
+}
+
+/**
+ * Called when the mouse button is depressed within the window, or any nested
+ * window.
+ */
+void MacStatsGraph::
+handle_button_press(int graph_x, int graph_y, bool double_click, int button) {
+  if (_potential_drag_mode != DM_none && button == 1) {
+    set_drag_mode(_potential_drag_mode);
+    _drag_start_x = graph_x;
+    _drag_start_y = graph_y;
+    // SetCapture(_window);
+  }
+}
+
+/**
+ * Called when the mouse button is released within the window, or any nested
+ * window.
+ */
+void MacStatsGraph::
+handle_button_release(int graph_x, int graph_y) {
+  set_drag_mode(DM_none);
+  // ReleaseCapture();
+
+  return handle_motion(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse is moved within the window, or any nested window.
+ */
+void MacStatsGraph::
+handle_motion(int graph_x, int graph_y) {
+  _potential_drag_mode = consider_drag_start(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse has left the graph window.
+ */
+void MacStatsGraph::
+handle_leave() {
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsGraph::
+handle_scroll() {
+  force_redraw();
+}
+
+/**
+ *
+ */
+void MacStatsGraph::
+handle_wheel(int graph_x, int graph_y, double dx, double dy) {
+}
+
+/**
+ *
+ */
+void MacStatsGraph::
+handle_magnify(int graph_x, int graph_y, double scale) {
+}
+
+/**
+ *
+ */
+void MacStatsGraph::
+handle_timer() {
+  _time += 1.0 / 60.0;
+
+  if (!animate(_time, 1.0 / 60.0)) {
+    [_animation_timer invalidate];
+    [_animation_timer release];
+    _animation_timer = nil;
+  }
+}
+
+/**
+ * Fills in the graph window.
+ */
+void MacStatsGraph::
+handle_draw_graph(CGContextRef ctx, NSRect rect) {
+  CGContextSetBlendMode(ctx, kCGBlendModeCopy);
+  CGContextSetInterpolationQuality(ctx, kCGInterpolationNone);
+
+  // Quantize this so that changed_graph_size will always call force_redraw()
+  NSRect full_rect = _graph_view.bounds;
+  full_rect.size.width = (int)full_rect.size.width;
+  full_rect.size.height = (int)full_rect.size.height;
+
+  CGSize size = CGContextConvertSizeToDeviceSpace(ctx, full_rect.size);
+  int width = abs((int)size.width);
+  int height = abs((int)size.height);
+
+  if (_ctx == nullptr || _bitmap_xsize != width || _bitmap_ysize != height) {
+    if (_ctx == nullptr && _scale_area != nullptr) {
+      _scale_area.needsDisplay = YES;
+    }
+    setup_bitmap(width, height, _window.backingScaleFactor);
+
+    changed_graph_size(full_rect.size.width, full_rect.size.height);
+  }
+
+  CGImageRef image = CGBitmapContextCreateImage(_ctx);
+  CGContextDrawImage(ctx, CGRectMake(0, 0, full_rect.size.width, full_rect.size.height), image);
+  CGImageRelease(image);
+}
+
+/**
+ * Fills in the graph window overhang, which is the area outside the graph
+ * bounds that may become visible momentarily due to scroll elasticity.
+ */
+void MacStatsGraph::
+handle_draw_graph_overhang(CGContextRef ctx, NSRect rect) {
+}
+
+/**
+ * Fills in the scale area.
+ */
+void MacStatsGraph::
+handle_draw_scale_area(CGContextRef ctx, NSRect rect) {
+}
+
+/**
+ * Called when the mouse clicks the back button in the toolbar.
+ */
+void MacStatsGraph::
+handle_back() {
+}
+
+/**
+ * Sets up a backing-store bitmap of the indicated size.
+ */
+void MacStatsGraph::
+setup_bitmap(int xsize, int ysize, double scale) {
+  release_bitmap();
+
+  _bitmap_xsize = xsize;
+  _bitmap_ysize = ysize;
+
+  // Ostensibly, a layer context is more efficient, but I tried it and the
+  // performance was horrible compared to a bitmap context.
+  _ctx = CGBitmapContextCreate(nullptr, xsize, ysize, 8, 0, CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB), kCGImageAlphaPremultipliedLast);
+  CGContextSetBlendMode(_ctx, kCGBlendModeCopy);
+  CGContextScaleCTM(_ctx, scale, scale);
+}
+
+/**
+ * Frees the backing-store bitmap created by setup_bitmap().
+ */
+void MacStatsGraph::
+release_bitmap() {
+  if (_ctx != nullptr) {
+    CGContextRelease(_ctx);
+    _ctx = nullptr;
+  }
+}

+ 31 - 0
pandatool/src/mac-stats/macStatsGraphView.h

@@ -0,0 +1,31 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsGraphView.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSGRAPHVIEW_H
+#define MACSTATSGRAPHVIEW_H
+
+#include "cocoa_compat.h"
+
+class MacStatsGraph;
+
+@interface MacStatsGraphView : NSView<NSViewToolTipOwner> {
+  @public
+    MacStatsGraph *_graph;
+}
+
+- (id)initWithGraph:(MacStatsGraph *)graph;
+- (void)drawRect:(NSRect)dirtyRect;
+
+@end
+
+#endif

+ 158 - 0
pandatool/src/mac-stats/macStatsGraphView.mm

@@ -0,0 +1,158 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsGraphView.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsGraphView.h"
+#include "macStatsGraph.h"
+#include "macStatsStripChart.h"
+#include "macStatsFlameGraph.h"
+#include "macStatsTimeline.h"
+#include "macStatsScaleArea.h"
+
+@implementation MacStatsGraphView
+
+- (id)initWithGraph:(MacStatsGraph *)graph {
+  if (self = [super init]) {
+    _graph = graph;
+
+    self.translatesAutoresizingMaskIntoConstraints = NO;
+
+    NSTrackingArea *area = [[NSTrackingArea alloc] initWithRect:NSZeroRect options:(NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect) owner:self userInfo:nil];
+    [self addTrackingArea:area];
+    [area release];
+
+    [self addToolTipRect:NSMakeRect(0, 0, 1000, 1000) owner:self userData:nil];
+  }
+
+  return self;
+}
+
+- (BOOL)isFlipped {
+  return YES;
+}
+
+- (BOOL)acceptsFirstResponder {
+  return YES;
+}
+
+- (void)keyDown:(NSEvent *)event {
+  if (!event.isARepeat) {
+    NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+    UniChar c = 0;
+    NSString *str = [event charactersIgnoringModifiers];
+    if (str != nil && str.length == 1) {
+      c = [str characterAtIndex:0];
+    }
+    _graph->handle_key(pos.x, pos.y, true, c, event.keyCode);
+  }
+}
+
+- (void)keyUp:(NSEvent *)event {
+  NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+  UniChar c = 0;
+  NSString *str = [event charactersIgnoringModifiers];
+  if (str != nil && str.length == 1) {
+    c = [str characterAtIndex:0];
+  }
+  _graph->handle_key(pos.x, pos.y, false, c, event.keyCode);
+}
+
+- (void)mouseDown:(NSEvent *)event {
+  NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+  _graph->handle_button_press(pos.x, pos.y, event.clickCount > 1, event.buttonNumber);
+}
+
+- (void)mouseUp:(NSEvent *)event {
+  NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+  _graph->handle_button_release(pos.x, pos.y);
+}
+
+- (void)mouseDragged:(NSEvent *)event {
+  NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+  _graph->handle_motion(pos.x, pos.y);
+}
+
+- (void)mouseMoved:(NSEvent *)event {
+  NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+  _graph->handle_motion(pos.x, pos.y);
+}
+
+- (void)mouseExited:(NSEvent *)event {
+  _graph->handle_leave();
+}
+
+- (void)scrollWheel:(NSEvent *)event {
+  [super scrollWheel:event];
+  if (event.deltaX != 0) {
+    NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+    _graph->handle_wheel(pos.x, pos.y, event.deltaX, event.deltaY);
+  }
+}
+
+- (void)magnifyWithEvent:(NSEvent *)event {
+  NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+  _graph->handle_magnify(pos.x, pos.y, event.magnification);
+}
+
+- (void)handleTimer:(NSTimer *)timer {
+  _graph->handle_timer();
+}
+
+- (void)viewDidChangeEffectiveAppearance {
+  if (_graph != nullptr) {
+    // Don't call this initially
+    if (self.window != nil) {
+      [NSAppearance setCurrentAppearance:self.effectiveAppearance];
+      _graph->force_redraw();
+    }
+  }
+}
+
+- (void)drawRect:(NSRect)dirtyRect {
+  if (_graph != nullptr) {
+    CGContextRef ctx = [NSGraphicsContext currentContext].CGContext;
+    _graph->handle_draw_graph(ctx, dirtyRect);
+  }
+}
+
+// Not called when building with macOS 14 SDK due to change in clipsToBounds
+// (but there it simply calls drawRect with the overhang region)
+#if __MAC_OS_X_VERSION_MAX_ALLOWED < 140000
+- (void)drawBackgroundOverhangInRect:(NSRect)dirtyRect {
+  if (_graph != nullptr) {
+    CGContextRef ctx = [NSGraphicsContext currentContext].CGContext;
+    _graph->handle_draw_graph_overhang(ctx, dirtyRect);
+  }
+}
+#endif
+
+- (NSMenu *)menuForEvent:(NSEvent *)event {
+  if (_graph != nullptr) {
+    NSPoint pos = [self convertPoint:event.locationInWindow fromView:nil];
+    return _graph->get_graph_menu(pos.x, pos.y);
+  }
+  return nil;
+}
+
+- (NSString *)view:(NSView *)view
+  stringForToolTip:(NSToolTipTag)tag
+             point:(NSPoint)point
+          userData:(void *)data {
+
+  if (_graph != nullptr) {
+    std::string text = _graph->get_graph_tooltip(point.x, point.y);
+    return [NSString stringWithUTF8String:text.c_str()];
+  }
+  return @"";
+}
+
+@end

+ 42 - 0
pandatool/src/mac-stats/macStatsGraphViewController.h

@@ -0,0 +1,42 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsGraphViewController.h
+ * @author rdb
+ * @date 2023-08-28
+ */
+
+#ifndef MACSTATSGRAPHVIEWCONTROLLER_H
+#define MACSTATSGRAPHVIEWCONTROLLER_H
+
+#include "macStatsGraphView.h"
+
+#import <Cocoa/Cocoa.h>
+
+class MacStatsGraph;
+
+@interface MacStatsGraphViewController : NSViewController<NSToolbarDelegate> {
+  @protected
+    MacStatsGraph *_graph;
+}
+
+- (id)initWithGraph:(MacStatsGraph *)graph;
+- (MacStatsGraphView *)graphView;
+- (BOOL)backToolbarItemVisible;
+- (void)setBackToolbarItemVisible:(BOOL)show;
+
+@end
+
+@interface MacStatsScrollableGraphViewController : MacStatsGraphViewController
+
+- (MacStatsGraphView *)graphView;
+- (NSClipView *)clipView;
+
+@end
+
+#endif

+ 224 - 0
pandatool/src/mac-stats/macStatsGraphViewController.mm

@@ -0,0 +1,224 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsGraphViewController.mm
+ * @author rdb
+ * @date 2023-08-28
+ */
+
+#include "macStatsGraphViewController.h"
+#include "macStatsGraph.h"
+#include "macStatsMonitor.h"
+#include "cocoa_compat.h"
+
+@implementation MacStatsGraphViewController
+
+- (id)initWithGraph:(MacStatsGraph *)graph {
+  if (self = [super init]) {
+    _graph = graph;
+  }
+
+  return self;
+}
+
+- (void)windowWillClose:(NSNotification *)notification {
+  MacStatsGraph *graph = _graph;
+  if (graph != nullptr) {
+    MacStatsMonitor *monitor = graph->get_monitor();
+    if (monitor != nullptr) {
+      _graph = nullptr;
+      monitor->remove_graph(graph);
+    }
+  }
+}
+
+- (void)loadView {
+  NSView *graph_view = [[MacStatsGraphView alloc] initWithGraph:_graph];
+  NSView *background;
+  if (@available(macOS 10.14, *)) {
+    NSVisualEffectView *effect_view = [[NSVisualEffectView alloc] init];
+    effect_view.material = (NSVisualEffectMaterial)18;//NSVisualEffectMaterialContentBackground;
+    background = effect_view;
+  } else {
+    background = [[NSView alloc] init];
+    background.wantsLayer = YES;
+    background.layer.backgroundColor = [NSColor controlBackgroundColor].CGColor;
+  }
+  [background addSubview:graph_view];
+  self.view = background;
+
+  [graph_view.widthAnchor constraintEqualToAnchor:background.widthAnchor].active = YES;
+  [graph_view.heightAnchor constraintEqualToAnchor:background.heightAnchor].active = YES;
+  [graph_view release];
+  [background release];
+}
+
+- (MacStatsGraphView *)graphView {
+  return (MacStatsGraphView *)self.view.subviews[0];
+}
+
+- (void)handleSplitViewResize:(NSNotification *)notification {
+  NSSplitView *split_view = (NSSplitView *)notification.object;
+  NSWindow *window = split_view.window;
+  NSToolbar *toolbar = window.toolbar;
+  if ([split_view isSubviewCollapsed:split_view.arrangedSubviews[0]]) {
+    if (toolbar.items[0].itemIdentifier != NSToolbarToggleSidebarItemIdentifier) {
+      [toolbar insertItemWithItemIdentifier:NSToolbarToggleSidebarItemIdentifier atIndex:0];
+    }
+  } else {
+    if (toolbar.items[0].itemIdentifier == NSToolbarToggleSidebarItemIdentifier) {
+      [toolbar removeItemAtIndex:0];
+    }
+  }
+}
+
+- (BOOL)backToolbarItemVisible {
+  NSToolbar *toolbar = self.view.window.toolbar;
+  if ([toolbar.items[0].itemIdentifier isEqual:@"back"] ||
+      [toolbar.items[1].itemIdentifier isEqual:@"back"]) {
+    return YES;
+  } else {
+    return NO;
+  }
+}
+
+- (void)setBackToolbarItemVisible:(BOOL)show {
+  NSToolbar *toolbar = self.view.window.toolbar;
+  if ([toolbar.items[1].itemIdentifier isEqual:@"back"]) {
+    if (!show) {
+      [toolbar removeItemAtIndex:1];
+    }
+  }
+  else if ([toolbar.items[0].itemIdentifier isEqual:@"back"]) {
+    if (!show) {
+      [toolbar removeItemAtIndex:0];
+    }
+  }
+  else if (show) {
+    // Insert it after the sidebar toggle, if we have one.
+    int index = ([toolbar.items[0].itemIdentifier isEqual:NSToolbarToggleSidebarItemIdentifier]);
+    [toolbar insertItemWithItemIdentifier:@"back" atIndex:index];
+  }
+}
+
+- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar {
+  return @[];
+}
+
+- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar {
+  return @[];
+}
+
+- (NSToolbarItem *) toolbar:(NSToolbar *)toolbar
+      itemForItemIdentifier:(NSString *)ident
+  willBeInsertedIntoToolbar:(BOOL)flag {
+
+  if (@available(macOS 11.0, *)) {
+    if ([ident isEqual:@"sep"]) {
+      return [NSTrackingSeparatorToolbarItem trackingSeparatorToolbarItemWithIdentifier:@"sep" splitView:_graph->get_split_view() dividerIndex:0];
+    }
+
+    if ([ident isEqual:@"back"]) {
+      NSToolbarItem *item = [[[NSToolbarItem alloc] initWithItemIdentifier:ident] autorelease];
+      item.label = @"Back";
+      item.image = [NSImage imageWithSystemSymbolName:@"chevron.left" accessibilityDescription:@""];
+      item.target = self;
+      item.action = @selector(handleBack:);
+      [item setNavigational:YES];
+      [item setBordered:YES];
+      return item;
+    }
+  }
+
+  return nil;
+}
+
+- (void)handleSetAsFocus:(NSMenuItem *)item {
+  _graph->on_click_label(item.tag);
+}
+
+- (void)handleChangeColor:(NSMenuItem *)item {
+  _graph->get_monitor()->choose_collector_color(item.tag);
+}
+
+- (void)handleResetColor:(NSMenuItem *)item {
+  _graph->get_monitor()->reset_collector_color(item.tag);
+}
+
+- (void)handleBack:(id)sender {
+  _graph->handle_back();
+}
+
+@end
+
+@implementation MacStatsScrollableGraphViewController
+
+- (void)loadView {
+  NSView *graph_view = [[MacStatsGraphView alloc] initWithGraph:_graph];
+  NSScrollView *scroll = [[NSScrollView alloc] init];
+  scroll.hasHorizontalScroller = NO;
+  scroll.hasVerticalScroller = YES;
+  scroll.horizontalScrollElasticity = NSScrollElasticityNone;
+  scroll.usesPredominantAxisScrolling = NO;
+  scroll.drawsBackground = YES;
+  scroll.scrollerStyle = NSScrollerStyleOverlay;
+  scroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
+  //scroll.translatesAutoresizingMaskIntoConstraints = NO;
+  scroll.automaticallyAdjustsContentInsets = YES;
+  scroll.documentView = graph_view;
+  self.view = scroll;
+
+  [graph_view.widthAnchor constraintEqualToAnchor:scroll.widthAnchor].active = YES;
+
+  if (@available(macOS 11.0, *)) {
+    [graph_view.heightAnchor constraintGreaterThanOrEqualToAnchor:((NSLayoutGuide *)[scroll safeAreaLayoutGuide]).heightAnchor].active = YES;
+  } else {
+    [graph_view.heightAnchor constraintGreaterThanOrEqualToAnchor:scroll.heightAnchor].active = YES;
+  }
+
+  NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+  [center addObserver:self
+           selector:@selector(handleScroll:)
+               name:NSScrollViewDidLiveScrollNotification
+             object:scroll];
+  [scroll release];
+  [graph_view release];
+}
+
+- (MacStatsGraphView *)graphView {
+  return (MacStatsGraphView *)((NSScrollView *)self.view).documentView;
+}
+
+- (NSClipView *)clipView {
+  return ((NSScrollView *)self.view).contentView;
+}
+
+- (void)viewDidLayout {
+  if (_graph != nullptr) {
+    _graph->handle_scroll();
+  }
+}
+
+- (void)handleScroll:(NSNotification *)notification {
+  if (_graph != nullptr) {
+    _graph->handle_scroll();
+  }
+}
+
+- (void)handleSideScroll:(NSNotification *)notification {
+  // Graph view is flipped, side bar isn't, so we need to convert coordinates
+  NSScrollView *side_sv = ((NSScrollView *)notification.object);
+  NSScrollView *graph_sv = (NSScrollView *)self.view;
+  NSPoint point;
+  point.x = 0;
+  point.y = self.graphView.frame.size.height - (side_sv.documentVisibleRect.size.height + side_sv.documentVisibleRect.origin.y) - graph_sv.contentInsets.top;
+  [graph_sv.contentView scrollToPoint:point];
+  [graph_sv reflectScrolledClipView:graph_sv.contentView];
+}
+
+@end

+ 58 - 0
pandatool/src/mac-stats/macStatsLabel.h

@@ -0,0 +1,58 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsLabel.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSLABEL_H
+#define MACSTATSLABEL_H
+
+#include "pandatoolbase.h"
+#include "luse.h"
+
+#include "cocoa_compat.h"
+
+class MacStatsMonitor;
+class MacStatsGraph;
+
+/**
+ * A text label that will draw in color appropriate for a particular
+ * collector.  It also responds when the user double-clicks on it.  This is
+ * handy for putting colored labels on strip charts.
+ */
+@interface MacStatsLabel : NSTextField<NSViewToolTipOwner> {
+  @private
+    MacStatsGraph *_graph;
+    int _thread_index;
+    int _collector_index;
+    bool _highlight;
+    bool _mouse_within;
+    NSColor *_fg_color;
+    NSColor *_highlight_fg_color;
+    NSColor *_bg_color;
+    NSColor *_highlight_bg_color;
+}
+
+- (id)initWithText:(NSString *)text
+             graph:(MacStatsGraph *)graph
+       threadIndex:(int)thread_index
+    collectorIndex:(int)collector_index;
+
+- (int)threadIndex;
+- (int)collectorIndex;
+
+- (void)updateColor;
+
+- (BOOL)highlight;
+- (void)setHighlight:(BOOL)highlight;
+
+@end
+
+#endif

+ 137 - 0
pandatool/src/mac-stats/macStatsLabel.mm

@@ -0,0 +1,137 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsLabel.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsLabel.h"
+#include "macStatsMonitor.h"
+#include "macStatsGraph.h"
+
+@implementation MacStatsLabel
+
+- (id)initWithText:(NSString *)text
+             graph:(MacStatsGraph *)graph
+       threadIndex:(int)thread_index
+    collectorIndex:(int)collector_index {
+  if (self = [super init]) {
+    _graph = graph;
+    _thread_index = thread_index;
+    _collector_index = collector_index;
+    _bg_color = nil;
+    _highlight_bg_color = nil;
+
+    [self setStringValue:text];
+    self.bezeled = NO;
+    self.drawsBackground = YES;
+    self.selectable = NO;
+    self.editable = NO;
+    self.lineBreakMode = NSLineBreakByTruncatingTail;
+    //self.autoresizingMask = NSViewWidthSizable | NSViewMaxXMargin | NSViewMinXMargin;
+    //self.translatesAutoresizingMaskIntoConstraints = NO;
+
+    NSTrackingArea *area = [[NSTrackingArea alloc] initWithRect:NSZeroRect options:(NSTrackingMouseEnteredAndExited | NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect) owner:self userInfo:nil];
+    [self addTrackingArea:area];
+    [area release];
+
+    [self addToolTipRect:NSMakeRect(0, 0, 1000, 1000) owner:self userData:nil];
+
+    [self updateColor];
+  }
+
+  return self;
+}
+
+- (void)dealloc {
+  [_bg_color release];
+  [_highlight_bg_color release];
+  [super dealloc];
+}
+
+- (NSSize)intrinsicContentSize {
+  // Allow resizing down.
+  NSSize size = [super intrinsicContentSize];
+  return NSMakeSize(NSViewNoIntrinsicMetric, size.height);
+}
+
+- (int)threadIndex {
+  return _thread_index;
+}
+
+- (int)collectorIndex {
+  return _collector_index;
+}
+
+- (void)updateColor {
+  if (_bg_color != nil) {
+    [_bg_color release];
+  }
+  if (_highlight_bg_color != nil) {
+    [_highlight_bg_color release];
+  }
+
+  _bg_color = [NSColor colorWithCGColor:_graph->get_monitor()->get_collector_color(_collector_index, false)];
+  _highlight_bg_color = [NSColor colorWithCGColor:_graph->get_monitor()->get_collector_color(_collector_index, true)];
+
+  [_bg_color retain];
+  [_highlight_bg_color retain];
+
+  _fg_color = _graph->get_monitor()->get_collector_text_color(_collector_index, false);
+  _highlight_fg_color = _graph->get_monitor()->get_collector_text_color(_collector_index, true);
+
+  [self setHighlight:_highlight];
+}
+
+- (BOOL)highlight {
+  return _highlight;
+}
+
+- (void)setHighlight:(BOOL)highlight {
+  _highlight = highlight;
+  if (highlight || _mouse_within) {
+    self.backgroundColor = _highlight_bg_color;
+    self.textColor = _highlight_fg_color;
+  } else {
+    self.backgroundColor = _bg_color;
+    self.textColor = _fg_color;
+  }
+}
+
+- (void)mouseEntered:(NSEvent *)event {
+  _mouse_within = true;
+  self.backgroundColor = _highlight_bg_color;
+  self.textColor = _highlight_fg_color;
+}
+
+- (void)mouseExited:(NSEvent *)event {
+  _mouse_within = false;
+  [self setHighlight:_highlight];
+}
+
+- (void)mouseDown:(NSEvent *)event {
+  if (event.buttonNumber == 0 && event.clickCount == 2) {
+    _graph->on_click_label(_collector_index);
+  }
+}
+
+- (NSMenu *)menuForEvent:(NSEvent *)event {
+  return _graph->get_label_menu(_collector_index);
+}
+
+- (NSString *)view:(NSView *)view
+  stringForToolTip:(NSToolTipTag)tag
+             point:(NSPoint)point
+          userData:(void *)data {
+
+  std::string text = _graph->get_label_tooltip(_collector_index);
+  return [NSString stringWithUTF8String:text.c_str()];
+}
+
+@end

+ 57 - 0
pandatool/src/mac-stats/macStatsLabelStack.h

@@ -0,0 +1,57 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsLabelStack.h
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#ifndef MACSTATSLABELSTACK_H
+#define MACSTATSLABELSTACK_H
+
+#include "pandatoolbase.h"
+#include "pvector.h"
+#include "macStatsLabel.h"
+
+#include <Cocoa/Cocoa.h>
+
+class MacStatsMonitor;
+class MacStatsGraph;
+
+/**
+ * A widget that contains a stack of labels from bottom to top.
+ */
+class MacStatsLabelStack {
+public:
+  MacStatsLabelStack();
+  ~MacStatsLabelStack();
+
+  NSView *get_view() const;
+
+  int get_label_y(int label_index, NSView *target_view) const;
+  int get_label_height(int label_index) const;
+  int get_label_collector_index(int label_index) const;
+
+  void clear_labels();
+  int add_label(MacStatsMonitor *monitor, MacStatsGraph *graph,
+                int thread_index, int collector_index, bool use_fullname);
+  int get_num_labels() const;
+
+  void highlight_label(int collector_index);
+  void update_label_color(int collector_index);
+
+private:
+  NSStackView *_stack_view;
+  NSLayoutConstraint *_constraint;
+  int _highlight_label;
+
+  typedef pvector<MacStatsLabel *> Labels;
+  Labels _labels;
+};
+
+#endif

+ 174 - 0
pandatool/src/mac-stats/macStatsLabelStack.mm

@@ -0,0 +1,174 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsLabelStack.mm
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#include "macStatsLabelStack.h"
+#include "macStatsLabel.h"
+#include "macStatsMonitor.h"
+
+static const NSEdgeInsets insets = {8, 8, 8, 8};
+
+/**
+ *
+ */
+MacStatsLabelStack::
+MacStatsLabelStack() {
+  _stack_view = [[NSStackView alloc] init];
+  _stack_view.edgeInsets = insets;
+  _stack_view.orientation = NSUserInterfaceLayoutOrientationVertical;
+  _stack_view.alignment = NSLayoutAttributeRight;//NSLayoutAttributeCenterX;
+  _stack_view.distribution = NSStackViewDistributionGravityAreas;
+  _stack_view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
+  _stack_view.translatesAutoresizingMaskIntoConstraints = NO;
+  _stack_view.spacing = 0;
+  _highlight_label = -1;
+
+  // May never be wider than the widest label, or 68, whichever is larger
+  //FIXME
+  _constraint = [_stack_view.widthAnchor constraintLessThanOrEqualToConstant:68];
+  _constraint.active = NO;
+  [_constraint retain];
+}
+
+/**
+ *
+ */
+MacStatsLabelStack::
+~MacStatsLabelStack() {
+  clear_labels();
+  [_constraint release];
+  [_stack_view release];
+}
+
+/**
+ * Returns the view for this stack.
+ */
+NSView *MacStatsLabelStack::
+get_view() const {
+  return _stack_view;
+}
+
+/**
+ * Returns the y position of the indicated label's bottom edge, relative to
+ * the indicated target widget.
+ */
+int MacStatsLabelStack::
+get_label_y(int label_index, NSView *target_view) const {
+  nassertr(label_index >= 0 && label_index < (int)_labels.size(), 0);
+
+  MacStatsLabel *label = _labels[label_index];
+  NSPoint pos = [target_view convertPoint:NSMakePoint(0, label.frame.size.height) fromView:label];
+  return pos.y;
+}
+
+/**
+ * Returns the height of the indicated label.
+ */
+int MacStatsLabelStack::
+get_label_height(int label_index) const {
+  nassertr(label_index >= 0 && label_index < (int)_labels.size(), 0);
+  return _labels[label_index].frame.size.height;
+}
+
+/**
+ * Returns the collector index associated with the indicated label.
+ */
+int MacStatsLabelStack::
+get_label_collector_index(int label_index) const {
+  nassertr(label_index >= 0 && label_index < (int)_labels.size(), -1);
+  return _labels[label_index].collectorIndex;
+}
+
+/**
+ * Removes the set of labels and starts a new set.
+ */
+void MacStatsLabelStack::
+clear_labels() {
+  for (MacStatsLabel *label : _labels) {
+    [_stack_view removeView:label];
+    [label release];
+  }
+  _labels.clear();
+
+  _constraint.constant = 68;
+
+  NSRect frame = _stack_view.frame;
+  frame.size.width = 0;
+  _stack_view.frame = frame;
+}
+
+/**
+ * Adds a new label to the top of the stack; returns the new label index.
+ */
+int MacStatsLabelStack::
+add_label(MacStatsMonitor *monitor, MacStatsGraph *graph,
+          int thread_index, int collector_index, bool use_fullname) {
+  const PStatClientData *client_data = monitor->get_client_data();
+  std::string text;
+  if (use_fullname) {
+    text = client_data->get_collector_fullname(collector_index);
+  } else {
+    text = client_data->get_collector_name(collector_index);
+  }
+
+  MacStatsLabel *label = [MacStatsLabel alloc];
+  [label initWithText:[NSString stringWithUTF8String:text.c_str()]
+                graph:graph
+          threadIndex:thread_index
+       collectorIndex:collector_index];
+
+  [_stack_view insertView:label atIndex:0 inGravity:NSStackViewGravityBottom];
+  [label.leadingAnchor constraintEqualToAnchor:_stack_view.leadingAnchor constant:8].active = YES;
+  [label.trailingAnchor constraintLessThanOrEqualToAnchor:_stack_view.trailingAnchor constant:-8].active = YES;
+
+  _constraint.constant = std::max(_constraint.constant, label.intrinsicContentSize.width);
+
+  int label_index = (int)_labels.size();
+  _labels.push_back(label);
+
+  return label_index;
+}
+
+/**
+ * Returns the number of labels in the stack.
+ */
+int MacStatsLabelStack::
+get_num_labels() const {
+  return _labels.size();
+}
+
+/**
+ * Draws a highlight around the label representing the indicated collector,
+ * and removes the highlight from any other label.  Specify -1 to remove the
+ * highlight from all labels.
+ */
+void MacStatsLabelStack::
+highlight_label(int collector_index) {
+  if (_highlight_label != collector_index) {
+    _highlight_label = collector_index;
+    for (MacStatsLabel *label : _labels) {
+      label.highlight = (label.collectorIndex == _highlight_label);
+    }
+  }
+}
+
+/**
+ * Refreshes the color of the label with the given index.
+ */
+void MacStatsLabelStack::
+update_label_color(int collector_index) {
+  for (MacStatsLabel *label : _labels) {
+    if (label.collectorIndex == collector_index) {
+      [label updateColor];
+    }
+  }
+}

+ 117 - 0
pandatool/src/mac-stats/macStatsMonitor.h

@@ -0,0 +1,117 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsMonitor.h
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#ifndef MACSTATSMONITOR_H
+#define MACSTATSMONITOR_H
+
+#include "pandatoolbase.h"
+
+#include "pStatMonitor.h"
+#include "pointerTo.h"
+#include "pset.h"
+#include "pvector.h"
+#include "pmap.h"
+
+#import <Cocoa/Cocoa.h>
+
+class MacStatsGraph;
+class MacStatsServer;
+class MacStatsChartMenu;
+
+/**
+ * This class represents a connection to a PStatsClient and manages the data
+ * exchange with the client.
+ */
+class MacStatsMonitor : public PStatMonitor {
+public:
+  MacStatsMonitor(MacStatsServer *server);
+  virtual ~MacStatsMonitor();
+
+  void close();
+
+  virtual std::string get_monitor_name();
+
+  virtual void initialized();
+  virtual void got_hello();
+  virtual void got_bad_version(int client_major, int client_minor,
+                               int server_major, int server_minor);
+  virtual void new_collector(int collector_index);
+  virtual void new_thread(int thread_index);
+  virtual void new_data(int thread_index, int frame_number);
+  virtual void remove_thread(int thread_index);
+  virtual void lost_connection();
+  virtual void idle();
+  virtual bool has_idle();
+
+  virtual void user_guide_bars_changed();
+
+  PStatGraph *open_timeline();
+  PStatGraph *open_strip_chart(int thread_index, int collector_index, bool show_level);
+  PStatGraph *open_flame_graph(int thread_index, int collector_index = -1);
+  PStatGraph *open_piano_roll(int thread_index);
+
+  CGColorRef get_collector_color(int collector_index, bool highlight = false);
+  NSColor *get_collector_text_color(int collector_index, bool highlight = false);
+
+  void choose_collector_color(int collector_index);
+  void handle_choose_collector_color(const LRGBColor &color);
+  void reset_collector_color(int collector_index);
+
+  void set_show_status_item(bool show);
+  void set_time_units(int unit_mask);
+  void set_scroll_speed(double scroll_speed);
+  void set_pause(bool pause);
+
+  void add_graph(MacStatsGraph *graph);
+  void remove_graph(MacStatsGraph *graph);
+  void close_all_graphs();
+
+private:
+  void setup_speed_menu();
+  void update_status_bar();
+
+private:
+  typedef pset<MacStatsGraph *> Graphs;
+  Graphs _graphs;
+
+  typedef pvector<MacStatsChartMenu *> ChartMenus;
+  ChartMenus _chart_menus;
+
+  NSUserNotification *_notification = nullptr;
+  NSMenu *_main_menu;
+  NSMenuItem *_speed_menu_item = nullptr;
+  NSMenuItem *_speed_menu_item_1;
+  NSMenuItem *_speed_menu_item_2;
+  NSMenuItem *_speed_menu_item_3;
+  NSMenuItem *_speed_menu_item_6;
+  NSMenuItem *_speed_menu_item_12;
+  NSMenuItem *_speed_menu_item_pause;
+  int _next_chart_index;
+  NSStatusItem *_frame_rate_status_item = nullptr;
+  double _scroll_speed;
+  bool _pause;
+  bool _have_data = false;
+  int _choosing_color_collector_index;
+
+  struct ColorSet {
+    CGColorRef _bg[2];
+    NSColor *_fg[2];
+  };
+  typedef pmap<int, ColorSet> Colors;
+  Colors _colors;
+
+  friend class MacStatsGraph;
+  friend class MacStatsServer;
+};
+
+#endif

+ 650 - 0
pandatool/src/mac-stats/macStatsMonitor.mm

@@ -0,0 +1,650 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsMonitor.mm
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#include "macStatsMonitor.h"
+#include "macStats.h"
+#include "macStatsServer.h"
+#include "macStatsStripChart.h"
+#include "macStatsChartMenu.h"
+#include "macStatsPianoRoll.h"
+#include "macStatsFlameGraph.h"
+#include "macStatsTimeline.h"
+#include "pStatGraph.h"
+#include "pStatCollectorDef.h"
+
+#include "convert_srgb.h"
+
+/**
+ *
+ */
+MacStatsMonitor::
+MacStatsMonitor(MacStatsServer *server) : PStatMonitor(server) {
+  _main_menu = NSApp.mainMenu;
+
+  // These will be filled in later when the menu is created.
+  _scroll_speed = 0.0;
+  _pause = false;
+  _next_chart_index = 2;
+
+  setup_speed_menu();
+
+  if ([[NSUserDefaults standardUserDefaults] boolForKey:@"ShowStatusItem"]) {
+    set_show_status_item(true);
+  }
+}
+
+/**
+ *
+ */
+MacStatsMonitor::
+~MacStatsMonitor() {
+  close();
+}
+
+/**
+ * Closes all the graphs associated with this monitor.
+ */
+void MacStatsMonitor::
+close() {
+  PStatMonitor::close();
+
+  if (_notification != nil) {
+    NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+    [center removeDeliveredNotification:_notification];
+    [_notification release];
+    _notification = nil;
+  }
+
+  close_all_graphs();
+
+  if (_speed_menu_item != nullptr) {
+    [_main_menu removeItem:_speed_menu_item];
+    [_speed_menu_item release];
+    _speed_menu_item = nullptr;
+  }
+
+  for (MacStatsChartMenu *chart_menu : _chart_menus) {
+    chart_menu->remove_from_menu(_main_menu);
+    delete chart_menu;
+  }
+  _chart_menus.clear();
+
+  set_show_status_item(false);
+
+  for (auto &item : _colors) {
+    CGColorRelease(item.second._bg[0]);
+    CGColorRelease(item.second._bg[1]);
+  }
+  _colors.clear();
+
+  _next_chart_index = 2;
+}
+
+/**
+ * Should be redefined to return a descriptive name for the type of
+ * PStatsMonitor this is.
+ */
+std::string MacStatsMonitor::
+get_monitor_name() {
+  return "MacStats";
+}
+
+/**
+ * Called after the monitor has been fully set up.  At this time, it will have
+ * a valid _client_data pointer, and things like is_alive() and close() will
+ * be meaningful.  However, we may not yet know who we're connected to
+ * (is_client_known() may return false), and we may not know anything about
+ * the threads or collectors we're about to get data on.
+ */
+void MacStatsMonitor::
+initialized() {
+}
+
+/**
+ * Called when the "hello" message has been received from the client.  At this
+ * time, the client's hostname and program name will be known.
+ */
+void MacStatsMonitor::
+got_hello() {
+  std::string progname = get_client_progname();
+  std::string hostname = get_client_hostname();
+
+  NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+  _notification = [[NSUserNotification alloc] init];
+  _notification.title = @"PStats Server";
+  _notification.informativeText = [NSString
+    stringWithFormat:@"Connected to %s on %s", progname.c_str(), hostname.c_str()];
+
+  [center deliverNotification:_notification];
+}
+
+/**
+ * Like got_hello(), this is called when the "hello" message has been received
+ * from the client.  At this time, the client's hostname and program name will
+ * be known.  However, the client appears to be an incompatible version and
+ * the connection will be terminated; the monitor should issue a message to
+ * that effect.
+ */
+void MacStatsMonitor::
+got_bad_version(int client_major, int client_minor,
+                int server_major, int server_minor) {
+  std::ostringstream str;
+  str << "Unable to honor connection attempt from "
+      << get_client_progname() << " on " << get_client_hostname()
+      << ": unsupported PStats version "
+      << client_major << "." << client_minor;
+
+  if (server_minor == 0) {
+    str << " (server understands version " << server_major
+        << "." << server_minor << " only).";
+  } else {
+    str << " (server understands versions " << server_major
+        << ".0 through " << server_major << "." << server_minor << ").";
+  }
+
+  std::string message = str.str();
+
+  NSAlert *alert = [[NSAlert alloc] init];
+  alert.messageText = @"PStats Error";
+  alert.informativeText = [NSString stringWithUTF8String:message.c_str()];
+  alert.alertStyle = NSCriticalAlertStyle;
+  [alert runModal];
+  [alert release];
+}
+
+/**
+ * Called whenever a new Collector definition is received from the client.
+ * Generally, the client will send all of its collectors over shortly after
+ * connecting, but there's no guarantee that they will all be received before
+ * the first frames are received.  The monitor should be prepared to accept
+ * new Collector definitions midstream.
+ */
+void MacStatsMonitor::
+new_collector(int collector_index) {
+  for (MacStatsGraph *graph : _graphs) {
+    graph->new_collector(collector_index);
+  }
+
+  // We might need to update our menus.
+  for (MacStatsChartMenu *chart_menu : _chart_menus) {
+    chart_menu->check_update();
+  }
+}
+
+/**
+ * Called whenever a new Thread definition is received from the client.
+ * Generally, the client will send all of its threads over shortly after
+ * connecting, but there's no guarantee that they will all be received before
+ * the first frames are received.  The monitor should be prepared to accept
+ * new Thread definitions midstream.
+ */
+void MacStatsMonitor::
+new_thread(int thread_index) {
+  MacStatsChartMenu *chart_menu = new MacStatsChartMenu(this, thread_index);
+  chart_menu->add_to_menu(_main_menu, _next_chart_index);
+  ++_next_chart_index;
+  _chart_menus.push_back(chart_menu);
+}
+
+/**
+ * Called as each frame's data is made available.  There is no guarantee the
+ * frames will arrive in order, or that all of them will arrive at all.  The
+ * monitor should be prepared to accept frames received out-of-order or
+ * missing.
+ */
+void MacStatsMonitor::
+new_data(int thread_index, int frame_number) {
+  for (MacStatsGraph *graph : _graphs) {
+    graph->new_data(thread_index, frame_number);
+  }
+
+  if (!_have_data) {
+    open_default_graphs();
+    _have_data = true;
+  }
+}
+
+/**
+ * Called when a thread should be removed from the list of threads.
+ */
+void MacStatsMonitor::
+remove_thread(int thread_index) {
+  for (ChartMenus::iterator it = _chart_menus.begin(); it != _chart_menus.end(); ++it) {
+    MacStatsChartMenu *chart_menu = *it;
+    if (chart_menu->get_thread_index() == thread_index) {
+      chart_menu->remove_from_menu(_main_menu);
+      delete chart_menu;
+      _chart_menus.erase(it);
+      --_next_chart_index;
+      return;
+    }
+  }
+}
+
+/**
+ * Called whenever the connection to the client has been lost.  This is a
+ * permanent state change.  The monitor should update its display to represent
+ * this, and may choose to close down automatically.
+ */
+void MacStatsMonitor::
+lost_connection() {
+  NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+
+  if (_notification != nil) {
+    [center removeDeliveredNotification:_notification];
+    [_notification release];
+  }
+
+  std::string hostname = get_client_hostname();
+
+  _notification = [[NSUserNotification alloc] init];
+  _notification.title = @"PStats Server";
+  _notification.informativeText = [NSString
+    stringWithFormat:@"Lost connection to %s", hostname.c_str()];
+
+  _notification.additionalActions = @[
+    [NSUserNotificationAction actionWithIdentifier:@"new" title:@"New Session"],
+    [NSUserNotificationAction actionWithIdentifier:@"quit" title:@"Quit PStats"]
+  ];
+
+  [center deliverNotification:_notification];
+}
+
+/**
+ * If has_idle() returns true, this will be called periodically to allow the
+ * monitor to update its display or whatever it needs to do.
+ */
+void MacStatsMonitor::
+idle() {
+  // Check if any of our chart menus need updating.
+  for (MacStatsChartMenu *chart_menu : _chart_menus) {
+    chart_menu->check_update();
+  }
+
+  // Update the frame rate label from the main thread (thread 0).
+  if (_frame_rate_status_item != nil) {
+    const PStatThreadData *thread_data = get_client_data()->get_thread_data(0);
+    double frame_rate = thread_data->get_frame_rate();
+    if (frame_rate != 0.0f) {
+      _frame_rate_status_item.button.title =
+        [NSString stringWithFormat:@"%0.1f ms / %0.1f Hz", 1000.0f / frame_rate, frame_rate];
+    }
+  }
+}
+
+/**
+ * Should be redefined to return true if you want to redefine idle() and
+ * expect it to be called.
+ */
+bool MacStatsMonitor::
+has_idle() {
+  return true;
+}
+
+/**
+ * Called when the user guide bars have been changed.
+ */
+void MacStatsMonitor::
+user_guide_bars_changed() {
+  for (MacStatsGraph *graph : _graphs) {
+    graph->user_guide_bars_changed();
+  }
+}
+
+/**
+ * Opens a new timeline.
+ */
+PStatGraph *MacStatsMonitor::
+open_timeline() {
+  MacStatsTimeline *graph = new MacStatsTimeline(this);
+  add_graph(graph);
+  return graph;
+}
+
+/**
+ * Opens a new flame graph showing the indicated data.
+ */
+PStatGraph *MacStatsMonitor::
+open_flame_graph(int thread_index, int collector_index) {
+  MacStatsFlameGraph *graph =
+    new MacStatsFlameGraph(this, thread_index, collector_index);
+  add_graph(graph);
+  return graph;
+}
+
+/**
+ * Opens a new strip chart showing the indicated data.
+ */
+PStatGraph *MacStatsMonitor::
+open_strip_chart(int thread_index, int collector_index, bool show_level) {
+  MacStatsStripChart *graph =
+    new MacStatsStripChart(this, thread_index, collector_index, show_level);
+  add_graph(graph);
+  return graph;
+}
+
+/**
+ * Opens a new piano roll showing the indicated data.
+ */
+PStatGraph *MacStatsMonitor::
+open_piano_roll(int thread_index) {
+  MacStatsPianoRoll *graph = new MacStatsPianoRoll(this, thread_index);
+  add_graph(graph);
+  return graph;
+}
+
+/**
+ * Returns a color suitable for drawing the indicated collector.
+ */
+CGColorRef MacStatsMonitor::
+get_collector_color(int collector_index, bool highlight) {
+  Colors::iterator bi;
+  bi = _colors.find(collector_index);
+  if (bi != _colors.end()) {
+    return (*bi).second._bg[highlight];
+  }
+
+  LRGBColor rgb = PStatMonitor::get_collector_color(collector_index);
+  rgb[0] = encode_sRGB_float(rgb[0]);
+  rgb[1] = encode_sRGB_float(rgb[1]);
+  rgb[2] = encode_sRGB_float(rgb[2]);
+
+  PN_stdfloat bright = rgb.dot(LRGBColor(0.2126, 0.7152, 0.0722));
+  CGColorRef color = CGColorCreateGenericRGB(rgb[0], rgb[1], rgb[2], 1.0);
+
+  rgb *= 0.75;
+  CGColorRef hcolor = CGColorCreateGenericRGB(rgb[0], rgb[1], rgb[2], 1.0);
+
+  ColorSet &set = _colors[collector_index];
+  set._bg[0] = color;
+  set._bg[1] = hcolor;
+
+  if (bright >= 0.5) {
+    set._fg[0] = [NSColor blackColor];
+  } else {
+    set._fg[0] = [NSColor whiteColor];
+  }
+
+  if (bright * 0.75 >= 0.5) {
+    set._fg[1] = [NSColor blackColor];
+  } else {
+    set._fg[1] = [NSColor whiteColor];
+  }
+
+  return highlight ? hcolor : color;
+}
+
+/**
+ * Returns a color suitable for drawing text on the indicated collector.
+ */
+NSColor *MacStatsMonitor::
+get_collector_text_color(int collector_index, bool highlight) {
+  get_collector_color(collector_index, false);
+  return _colors[collector_index]._fg[highlight];
+}
+
+/**
+ * Opens a dialog to change the given collector color.
+ */
+void MacStatsMonitor::
+choose_collector_color(int collector_index) {
+  _choosing_color_collector_index = collector_index;
+
+  const LRGBColor &rgb = PStatMonitor::get_collector_color(collector_index);
+  NSColor *color = [NSColor colorWithSRGBRed:rgb[0] green:rgb[1] blue:rgb[2] alpha:1.0];
+
+  NSColorPanel *panel = [NSColorPanel sharedColorPanel];
+  panel.target = [NSApp delegate];
+  panel.action = @selector(handleChooseCollectorColor:);
+  panel.showsAlpha = NO;
+  panel.color = color;
+  [NSApp orderFrontColorPanel:panel];
+}
+
+/**
+ * Sets a custom color associated with the given collector.
+ */
+void MacStatsMonitor::
+handle_choose_collector_color(const LRGBColor &color) {
+  int collector_index = _choosing_color_collector_index;
+
+  PStatMonitor::set_collector_color(collector_index, color);
+
+  if (_colors.erase(collector_index)) {
+    for (MacStatsGraph *graph : _graphs) {
+      graph->reset_collector_color(collector_index);
+    }
+  }
+}
+
+/**
+ * Resets the color of the given collector to the default.
+ */
+void MacStatsMonitor::
+reset_collector_color(int collector_index) {
+  PStatMonitor::clear_collector_color(collector_index);
+
+  if (_colors.erase(collector_index)) {
+    for (MacStatsGraph *graph : _graphs) {
+      graph->reset_collector_color(collector_index);
+    }
+  }
+}
+
+/**
+ * Enables the frame rate label on the right end of the menu bar.  This is
+ * used as a text label to display the main thread's frame rate to the user,
+ * although it is implemented as a right-justified toplevel menu item that
+ * doesn't open to anything.
+ */
+void MacStatsMonitor::
+set_show_status_item(bool show) {
+  if (show && _frame_rate_status_item == nil) {
+    _frame_rate_status_item = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
+    _frame_rate_status_item.button.action = @selector(handleClickStatusItem:);
+    [_frame_rate_status_item retain];
+
+    const PStatClientData *client_data = get_client_data();
+    if (client_data != nullptr) {
+      const PStatThreadData *thread_data = client_data->get_thread_data(0);
+      if (thread_data != nullptr) {
+        double frame_rate = thread_data->get_frame_rate();
+        if (frame_rate != 0.0f) {
+          _frame_rate_status_item.button.title =
+            [NSString stringWithFormat:@"%0.1f ms / %0.1f Hz", 1000.0f / frame_rate, frame_rate];
+        }
+      }
+    }
+  }
+  if (!show && _frame_rate_status_item != nil) {
+    // Careful: for some reason, this can get called recursively.
+    NSStatusItem *status_item = _frame_rate_status_item;
+    _frame_rate_status_item = nil;
+    [[NSStatusBar systemStatusBar] removeStatusItem:status_item];
+    [status_item release];
+    status_item = nil;
+  }
+}
+
+/**
+ * Called when the user selects a new time units from the monitor pulldown
+ * menu, this should adjust the units for all graphs to the indicated mask if
+ * it is a time-based graph.
+ */
+void MacStatsMonitor::
+set_time_units(int unit_mask) {
+  for (MacStatsGraph *graph : _graphs) {
+    graph->set_time_units(unit_mask);
+  }
+}
+
+/**
+ * Called when the user selects a new scroll speed from the monitor pulldown
+ * menu, this should adjust the speeds for all graphs to the indicated value.
+ */
+void MacStatsMonitor::
+set_scroll_speed(double scroll_speed) {
+  _scroll_speed = scroll_speed;
+
+  // First, change all of the open graphs appropriately.
+  for (MacStatsGraph *graph : _graphs) {
+    graph->set_scroll_speed(_scroll_speed);
+  }
+
+  // Then, change the state of the menu items.
+  _speed_menu_item_1.state = (scroll_speed == 1.0);
+  _speed_menu_item_2.state = (scroll_speed == 2.0);
+  _speed_menu_item_3.state = (scroll_speed == 3.0);
+  _speed_menu_item_6.state = (scroll_speed == 6.0);
+  _speed_menu_item_12.state = (scroll_speed == 12.0);
+}
+
+/**
+ * Called when the user selects a pause on or pause off option from the menu.
+ */
+void MacStatsMonitor::
+set_pause(bool pause) {
+  _pause = pause;
+
+  // First, change all of the open graphs appropriately.
+  for (MacStatsGraph *graph : _graphs) {
+    graph->set_pause(_pause);
+  }
+
+  // Then, change the state of the menu item.
+  _speed_menu_item_pause.state = pause;
+}
+
+/**
+ * Adds the newly-created graph to the list of managed graphs.
+ */
+void MacStatsMonitor::
+add_graph(MacStatsGraph *graph) {
+  _graphs.insert(graph);
+
+  int units = [[NSUserDefaults standardUserDefaults] integerForKey:@"TimeUnits"];
+  graph->set_time_units(units);
+  graph->set_scroll_speed(_scroll_speed);
+  graph->set_pause(_pause);
+}
+
+/**
+ * Deletes the indicated graph.
+ */
+void MacStatsMonitor::
+remove_graph(MacStatsGraph *graph) {
+  Graphs::iterator gi = _graphs.find(graph);
+  if (gi != _graphs.end()) {
+    _graphs.erase(gi);
+    delete graph;
+  }
+}
+
+/**
+ * Asks all open graphs to close.
+ */
+void MacStatsMonitor::
+close_all_graphs() {
+  while (!_graphs.empty()) {
+    (*_graphs.begin())->close();
+  }
+}
+
+/**
+ * Creates the "Speed" pulldown menu.
+ */
+void MacStatsMonitor::
+setup_speed_menu() {
+  NSMenu *menu = [[NSMenu alloc] init];
+  menu.title = @"Speed";
+  menu.autoenablesItems = NO;
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSpeed:);
+    item.title = @"1";
+    item.tag = 1;
+    [menu addItem:item];
+    [item release];
+
+    _speed_menu_item_1 = item;
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSpeed:);
+    item.title = @"2";
+    item.tag = 2;
+    [menu addItem:item];
+    [item release];
+
+    _speed_menu_item_2 = item;
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSpeed:);
+    item.title = @"3";
+    item.tag = 3;
+    item.state = NSOnState;
+    [menu addItem:item];
+    [item release];
+
+    _speed_menu_item_3 = item;
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSpeed:);
+    item.title = @"6";
+    item.tag = 6;
+    [menu addItem:item];
+    [item release];
+
+    _speed_menu_item_6 = item;
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSpeed:);
+    item.title = @"12";
+    item.tag = 12;
+    [menu addItem:item];
+    [item release];
+
+    _speed_menu_item_12 = item;
+  }
+
+  [menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handlePause:);
+    item.title = @"pause";
+    [menu addItem:item];
+    [item release];
+
+    _speed_menu_item_pause = item;
+  }
+
+  NSMenuItem *item = [[NSMenuItem alloc] init];
+  _speed_menu_item = item;
+  [_main_menu addItem:item];
+  [_main_menu setSubmenu:menu forItem:item];
+  [menu release];
+
+  set_scroll_speed(3);
+  set_pause(false);
+
+  ++_next_chart_index;
+}

+ 84 - 0
pandatool/src/mac-stats/macStatsPianoRoll.h

@@ -0,0 +1,84 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsPianoRoll.h
+ * @author rdb
+ * @date 2023-08-19
+ */
+
+#ifndef MACSTATSPIANOROLL_H
+#define MACSTATSPIANOROLL_H
+
+#include "macStatsGraph.h"
+#include "pStatPianoRoll.h"
+#include "macStatsChartMenuDelegate.h"
+
+class MacStatsMonitor;
+
+/**
+ * A window that draws a piano-roll style chart, which shows the collectors
+ * explicitly stopping and starting, one frame at a time.
+ */
+class MacStatsPianoRoll final : public PStatPianoRoll, public MacStatsGraph {
+public:
+  MacStatsPianoRoll(MacStatsMonitor *monitor, int thread_index);
+  virtual ~MacStatsPianoRoll();
+
+  virtual void new_data(int thread_index, int frame_number);
+  virtual void force_redraw();
+  virtual void changed_graph_size(int graph_xsize, int graph_ysize);
+
+  virtual void set_time_units(int unit_mask);
+  virtual void on_click_label(int collector_index);
+  virtual NSMenu *get_label_menu(int collector_index) const;
+  virtual std::string get_label_tooltip(int collector_index) const;
+  void set_horizontal_scale(double time_width);
+
+protected:
+  void clear_region();
+  virtual void begin_draw();
+  virtual void begin_row(int row);
+  virtual void draw_bar(int row, int from_x, int to_x);
+  virtual void end_draw();
+  virtual void idle();
+
+  virtual bool get_window_state(int &x, int &y, int &width, int &height,
+                                bool &maximized, bool &minimized) const;
+  virtual void set_window_state(int x, int y, int width, int height,
+                                bool maximized, bool minimized);
+
+  virtual NSMenu *get_graph_menu(int mouse_x, int mouse_y) const;
+  virtual std::string get_graph_tooltip(int mouse_x, int mouse_y) const;
+  virtual DragMode consider_drag_start(int graph_x, int graph_y);
+
+  virtual void handle_button_press(int graph_x, int graph_y,
+                                   bool double_click, int button);
+  virtual void handle_button_release(int graph_x, int graph_y);
+  virtual void handle_motion(int graph_x, int graph_y);
+  virtual void handle_leave();
+  virtual void handle_scroll();
+  virtual void handle_magnify(int graph_x, int graph_y, double scale);
+  virtual void handle_draw_graph(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_graph_overhang(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_scale_area(CGContextRef ctx, NSRect rect);
+
+private:
+  int get_collector_under_pixel(int xpoint, int ypoint) const;
+  void update_labels();
+  void draw_guide_bars(CGContextRef ctx, int y, int height);
+  void draw_guide_bar(CGContextRef ctx, const PStatGraph::GuideBar &bar,
+                      int y, int height);
+  void draw_guide_labels(CGContextRef ctx);
+  void draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar);
+
+private:
+  MacStatsChartMenuDelegate *_menu_delegate;
+  NSScrollView *_sidebar_scroll_view;
+};
+
+#endif

+ 764 - 0
pandatool/src/mac-stats/macStatsPianoRoll.mm

@@ -0,0 +1,764 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsPianoRoll.mm
+ * @author rdb
+ * @date 2023-08-19
+ */
+
+#include "macStatsPianoRoll.h"
+#include "macStatsMonitor.h"
+#include "macStatsLabelStack.h"
+#include "macStatsScaleArea.h"
+
+static const int default_piano_roll_width = 800;
+static const int default_piano_roll_height = 400;
+
+static const int minimum_piano_roll_sidebar_width = 68;
+static const int default_piano_roll_sidebar_width = 200;
+
+/**
+ *
+ */
+MacStatsPianoRoll::
+MacStatsPianoRoll(MacStatsMonitor *monitor, int thread_index) :
+  PStatPianoRoll(monitor, thread_index, 0, 0),
+  MacStatsGraph(monitor, [MacStatsScrollableGraphViewController alloc])
+{
+  // Used for popup menus.
+  _menu_delegate = [[MacStatsChartMenuDelegate alloc] initWithMonitor:monitor threadIndex:thread_index];
+
+  // Let's show the units on the guide bar labels.  There's room.
+  set_guide_bar_units(get_guide_bar_units() | GBU_show_units);
+
+  const PStatClientData *client_data =
+    MacStatsGraph::_monitor->get_client_data();
+  std::string thread_name = client_data->get_thread_name(_thread_index);
+  std::string window_title = thread_name + " thread piano roll";
+  _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+
+  if (@available(macOS 11.0, *)) {
+    _window.titleVisibility = NSWindowTitleHidden;
+  }
+
+  // Set the initial size of the graph.
+  _graph_view.frame = NSMakeRect(0, 0, default_piano_roll_width, default_piano_roll_height);
+  _graph_view_controller.view.frame = NSMakeRect(0, 0, default_piano_roll_width, default_piano_roll_height);
+
+  // It's put inside a scroll view that tracks the main scroll view.
+  NSScrollView *scroll_view = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 0, default_piano_roll_sidebar_width, 0)];
+  scroll_view.documentView = _label_stack.get_view();
+  scroll_view.drawsBackground = NO;
+  scroll_view.automaticallyAdjustsContentInsets = YES;
+  //scroll_view.translatesAutoresizingMaskIntoConstraints = NO;
+  scroll_view.hasHorizontalScroller = NO;
+  scroll_view.hasVerticalScroller = NO;
+  _sidebar_scroll_view = scroll_view;
+
+  NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+  [center addObserver:_graph_view_controller
+             selector:@selector(handleSideScroll:)
+                 name:NSScrollViewDidLiveScrollNotification
+               object:scroll_view];
+
+  NSViewController *sidebar_controller = [[NSViewController alloc] init];
+  sidebar_controller.view = scroll_view;
+
+  NSSplitViewController *svc = [[NSSplitViewController alloc] init];
+  [svc addSplitViewItem:[NSSplitViewItem sidebarWithViewController:sidebar_controller]];
+  [svc addSplitViewItem:[NSSplitViewItem splitViewItemWithViewController:_graph_view_controller]];
+
+  svc.splitViewItems[0].minimumThickness = minimum_piano_roll_sidebar_width;
+  svc.splitViewItems[0].canCollapse = NO;
+
+  NSSplitView *split_view = svc.splitView;
+  split_view.vertical = YES;
+  split_view.dividerStyle = NSSplitViewDividerStyleThin;
+  split_view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
+  _split_view = split_view;
+
+  _window.contentViewController = svc;
+
+  [svc release];
+  [sidebar_controller release];
+
+  // Scale area goes on top, as a titlebar accessory view.
+  MacStatsScaleAreaController *scale_area_controller = [[MacStatsScaleAreaController alloc] initWithGraph:this];
+  scale_area_controller.fullScreenMinHeight = 20;
+  scale_area_controller.layoutAttribute = NSLayoutAttributeRight;
+  _scale_area = scale_area_controller.view;
+  [_window addTitlebarAccessoryViewController:scale_area_controller];
+  [scale_area_controller release];
+
+  [_label_stack.get_view().widthAnchor constraintEqualToAnchor:scroll_view.widthAnchor].active = YES;
+  [_label_stack.get_view().heightAnchor constraintEqualToAnchor:_graph_view.heightAnchor].active = YES;
+
+  idle();
+
+  [_window makeKeyAndOrderFront:nil];
+}
+
+/**
+ *
+ */
+MacStatsPianoRoll::
+~MacStatsPianoRoll() {
+  [_sidebar_scroll_view release];
+  [_menu_delegate release];
+}
+
+/**
+ * Called as each frame's data is made available.  There is no guarantee the
+ * frames will arrive in order, or that all of them will arrive at all.  The
+ * monitor should be prepared to accept frames received out-of-order or
+ * missing.
+ */
+void MacStatsPianoRoll::
+new_data(int thread_index, int frame_number) {
+  if (!_pause) {
+    update();
+  }
+}
+
+/**
+ * Called when it is necessary to redraw the entire graph.
+ */
+void MacStatsPianoRoll::
+force_redraw() {
+  if (_ctx) {
+    PStatPianoRoll::force_redraw();
+  }
+}
+
+/**
+ * Called when the user has resized the window, forcing a resize of the graph.
+ */
+void MacStatsPianoRoll::
+changed_graph_size(int graph_xsize, int graph_ysize) {
+  PStatPianoRoll::changed_size(graph_xsize, graph_ysize);
+}
+
+/**
+ * Called when the user selects a new time units from the monitor pulldown
+ * menu, this should adjust the units for the graph to the indicated mask if
+ * it is a time-based graph.
+ */
+void MacStatsPianoRoll::
+set_time_units(int unit_mask) {
+  int old_unit_mask = get_guide_bar_units();
+  if ((old_unit_mask & (GBU_hz | GBU_ms)) != 0) {
+    unit_mask = unit_mask & (GBU_hz | GBU_ms);
+    unit_mask |= (old_unit_mask & GBU_show_units);
+    set_guide_bar_units(unit_mask);
+  }
+}
+
+/**
+ * Called when the user single-clicks on a label.
+ */
+void MacStatsPianoRoll::
+on_click_label(int collector_index) {
+  if (collector_index >= 0) {
+    MacStatsGraph::_monitor->open_strip_chart(_thread_index, collector_index, false);
+  }
+}
+
+/**
+ * Called when the mouse right-clicks on a label, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsPianoRoll::
+get_label_menu(int collector_index) const {
+  NSMenu *menu = [[[NSMenu alloc] init] autorelease];
+
+  std::string label = get_label_tooltip(collector_index);
+  if (!label.empty()) {
+    if (@available(macOS 14.0, *)) {
+      [menu addItem:[NSMenuItem sectionHeaderWithTitle:[NSString stringWithUTF8String:label.c_str()]]];
+    } else {
+      NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:label.c_str()] action:nil keyEquivalent:@""];
+      item.enabled = NO;
+      [menu addItem:item];
+      [item release];
+    }
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Strip Chart" action:@selector(handleOpenStripChart:) keyEquivalent:@""];
+    item.target = _menu_delegate;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Flame Graph" action:@selector(handleOpenFlameGraph:) keyEquivalent:@""];
+    item.target = _menu_delegate;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  [menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Change Color\u2026" action:@selector(handleChangeColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Reset Color" action:@selector(handleResetColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  return menu;
+}
+
+/**
+ * Called when the mouse hovers over a label, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsPianoRoll::
+get_label_tooltip(int collector_index) const {
+  return PStatPianoRoll::get_label_tooltip(collector_index);
+}
+
+/**
+ * Changes the amount of time the width of the horizontal axis represents.
+ * This may force a redraw.
+ */
+void MacStatsPianoRoll::
+set_horizontal_scale(double time_width) {
+  PStatPianoRoll::set_horizontal_scale(time_width);
+
+  _graph_view.needsDisplay = YES;
+}
+
+/**
+ * Erases the chart area.
+ */
+void MacStatsPianoRoll::
+clear_region() {
+  if (_ctx) {
+    CGContextSetFillColorWithColor(_ctx, _background_color);
+    CGContextFillRect(_ctx, CGRectMake(0, 0, get_xsize(), get_ysize()));
+  }
+}
+
+/**
+ * Erases the chart area in preparation for drawing a bunch of bars.
+ */
+void MacStatsPianoRoll::
+begin_draw() {
+  clear_region();
+
+  // Draw in the guide bars.
+  CGContextSetStrokeColorWithColor(_ctx, [NSColor gridColor].CGColor);
+  int num_guide_bars = get_num_guide_bars();
+  for (int i = 0; i < num_guide_bars; ++i) {
+    draw_guide_bar(_ctx, get_guide_bar(i), 0, get_ysize());
+  }
+}
+
+/**
+ * Should be overridden by the user class.  This hook will be called before
+ * drawing any one row of bars.  These bars correspond to the collector whose
+ * index is get_row_collector(row), and in the color get_row_color(row).
+ */
+void MacStatsPianoRoll::
+begin_row(int row) {
+  int collector_index = get_label_collector(row);
+  bool is_highlighted = collector_index == _highlighted_index;
+  CGContextSetFillColorWithColor(_ctx,
+    MacStatsGraph::_monitor->get_collector_color(collector_index, is_highlighted));
+}
+
+/**
+ * Draws a single bar on the chart.
+ */
+void MacStatsPianoRoll::
+draw_bar(int row, int from_x, int to_x) {
+  if (row >= 0 && row < _label_stack.get_num_labels()) {
+    int y = _label_stack.get_label_y(row, _graph_view);
+    int height = _label_stack.get_label_height(row);
+
+    CGContextFillRect(_ctx, CGRectMake(from_x, (y - height + 2), to_x - from_x, (height - 4)));
+  }
+}
+
+/**
+ * Called after all the bars have been drawn, this triggers a refresh event to
+ * draw it to the window.
+ */
+void MacStatsPianoRoll::
+end_draw() {
+  _graph_view.needsDisplay = YES;
+
+  if (_guide_bars_changed) {
+    _scale_area.needsDisplay = YES;
+    _guide_bars_changed = false;
+  }
+}
+
+/**
+ * Called at the end of the draw cycle.
+ */
+void MacStatsPianoRoll::
+idle() {
+  if (_labels_changed) {
+    update_labels();
+  }
+}
+
+/**
+ * Returns the current window dimensions.
+ */
+bool MacStatsPianoRoll::
+get_window_state(int &x, int &y, int &width, int &height,
+                 bool &maximized, bool &minimized) const {
+  MacStatsGraph::get_window_state(x, y, width, height, maximized, minimized);
+  return true;
+}
+
+/**
+ * Called to restore the graph window to its previous dimensions.
+ */
+void MacStatsPianoRoll::
+set_window_state(int x, int y, int width, int height,
+                 bool maximized, bool minimized) {
+  MacStatsGraph::set_window_state(x, y, width, height, maximized, minimized);
+}
+
+/**
+ * Called when the mouse right-clicks on the graph, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsPianoRoll::
+get_graph_menu(int mouse_x, int mouse_y) const {
+  int collector_index = get_collector_under_pixel(mouse_x, mouse_y);
+  if (collector_index >= 0) {
+    return get_label_menu(collector_index);
+  }
+  return nil;
+}
+
+/**
+ * Called when the mouse hovers over the graph, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsPianoRoll::
+get_graph_tooltip(int mouse_x, int mouse_y) const {
+  int collector_index = get_collector_under_pixel(mouse_x, mouse_y);
+  if (collector_index >= 0) {
+    return get_label_tooltip(collector_index);
+  }
+  return std::string();
+}
+
+/**
+ * Based on the mouse position within the graph window, look for draggable
+ * things the mouse might be hovering over and return the appropriate DragMode
+ * enum or DM_none if nothing is indicated.
+ */
+MacStatsGraph::DragMode MacStatsPianoRoll::
+consider_drag_start(int graph_x, int graph_y) {
+  if (graph_y >= 0 && graph_y < get_ysize()) {
+    if (graph_x >= 0 && graph_x < get_xsize()) {
+      // See if the mouse is over a user-defined guide bar.
+      int x = graph_x;
+      double from_height = pixel_to_height(x - 2);
+      double to_height = pixel_to_height(x + 2);
+      _drag_guide_bar = find_user_guide_bar(from_height, to_height);
+      if (_drag_guide_bar >= 0) {
+        return DM_guide_bar;
+      }
+
+    } else {
+      // The mouse is left or right of the graph; maybe create a new guide
+      // bar.
+      return DM_new_guide_bar;
+    }
+  }
+
+  return MacStatsGraph::consider_drag_start(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse button is depressed within the graph window.
+ */
+void MacStatsPianoRoll::
+handle_button_press(int graph_x, int graph_y, bool double_click, int button) {
+  if (graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    if (double_click && button == 1) {
+      // Double-clicking on a color bar in the graph is the same as double-
+      // clicking on the corresponding label.
+      on_click_label(get_collector_under_pixel(graph_x, graph_y));
+      return;
+    }
+  }
+
+  if (_potential_drag_mode == DM_none) {
+    set_drag_mode(DM_scale);
+    _drag_scale_start = pixel_to_height(graph_x);
+    // SetCapture(_graph_window);
+    return;
+
+  } else if (_potential_drag_mode == DM_guide_bar && _drag_guide_bar >= 0) {
+    set_drag_mode(DM_guide_bar);
+    _drag_start_x = graph_x;
+    // SetCapture(_graph_window);
+    return;
+  }
+
+  return MacStatsGraph::handle_button_press(graph_x, graph_y,
+                                            double_click, button);
+}
+
+/**
+ * Called when the mouse button is released within the graph window.
+ */
+void MacStatsPianoRoll::
+handle_button_release(int graph_x, int graph_y) {
+  if (_drag_mode == DM_scale) {
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+  else if (_drag_mode == DM_guide_bar) {
+    if (graph_x < 0 || graph_x >= get_xsize()) {
+      remove_user_guide_bar(_drag_guide_bar);
+    } else {
+      move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_x));
+    }
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+
+  return MacStatsGraph::handle_button_release(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsPianoRoll::
+handle_motion(int graph_x, int graph_y) {
+  if (_drag_mode == DM_none && _potential_drag_mode == DM_none) {
+    // When the mouse is over a color bar, highlight it.
+    int collector_index = get_collector_under_pixel(graph_x, graph_y);
+    _label_stack.highlight_label(collector_index);
+    on_enter_label(collector_index);
+
+    /*
+    // Now we want to get a WM_MOUSELEAVE when the mouse leaves the graph
+    // window.
+    TRACKMOUSEEVENT tme = {
+      sizeof(TRACKMOUSEEVENT),
+      TME_LEAVE,
+      _graph_window,
+      0
+    };
+    TrackMouseEvent(&tme);
+    */
+  }
+  else {
+    // If the mouse is in some drag mode, stop highlighting.
+    _label_stack.highlight_label(-1);
+    on_leave_label(_highlighted_index);
+  }
+
+  if (_drag_mode == DM_scale) {
+    double ratio = (double)graph_x / (double)get_xsize();
+    if (ratio > 0.0f) {
+      set_horizontal_scale(_drag_scale_start / ratio);
+    }
+    return;
+  }
+  else if (_drag_mode == DM_new_guide_bar) {
+    // We haven't created the new guide bar yet; we won't until the mouse
+    // comes within the graph's region.
+    if (graph_x >= 0 && graph_x < get_xsize()) {
+      set_drag_mode(DM_guide_bar);
+      _drag_guide_bar = add_user_guide_bar(pixel_to_height(graph_x));
+      return;
+    }
+
+  } else if (_drag_mode == DM_guide_bar) {
+    move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_x));
+    return;
+  }
+
+  return MacStatsGraph::handle_motion(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse has left the graph window.
+ */
+void MacStatsPianoRoll::
+handle_leave() {
+  _label_stack.highlight_label(-1);
+  on_leave_label(_highlighted_index);
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsPianoRoll::
+handle_scroll() {
+  // Graph view is flipped, side bar isn't, so we need to convert coordinates
+  NSPoint point;
+  point.x = 0;
+  point.y = _graph_view.frame.size.height - (((NSScrollView *)_graph_view_controller.view).documentVisibleRect.size.height + ((NSScrollView *)_graph_view_controller.view).documentVisibleRect.origin.y);
+  [_sidebar_scroll_view.contentView scrollToPoint:point];
+  [_sidebar_scroll_view reflectScrolledClipView:_sidebar_scroll_view.contentView];
+}
+
+/**
+ *
+ */
+void MacStatsPianoRoll::
+handle_magnify(int graph_x, int graph_y, double scale) {
+  set_horizontal_scale(get_horizontal_scale() * (1.0 - scale));
+}
+
+/**
+ * Fills in the graph window.
+ */
+void MacStatsPianoRoll::
+handle_draw_graph(CGContextRef ctx, NSRect rect) {
+#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 140000
+  draw_guide_bars(ctx, rect.origin.y, rect.size.height);
+#endif
+
+  // Copy the drawn bars into the graph.
+  MacStatsGraph::handle_draw_graph(ctx, rect);
+
+  // Draw the scale area.
+/*
+  CGContextSetRGBStrokeColor(ctx, rgb_dark_gray[0], rgb_dark_gray[1], rgb_dark_gray[2], 1.0);
+  CGContextSetStrokeColor(ctx, rgb_dark_gray);
+  CGContextBeginPath(ctx);
+  CGContextMoveToPoint(ctx, 0, header_height);
+  CGContextAddLineToPoint(ctx, get_xsize(), header_height);
+  CGContextStrokePath(ctx);*/
+
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (int i = 0; i < num_user_guide_bars; ++i) {
+    draw_guide_bar(ctx, get_user_guide_bar(i), rect.origin.y, rect.size.height);
+  }
+
+  NSRect scale_frame = _scale_area.frame;
+  NSRect graph_frame = _graph_view.frame;
+  if (scale_frame.size.width != graph_frame.size.width) {
+    scale_frame.size.width = graph_frame.size.width;
+    _scale_area.frame = scale_frame;
+  }
+}
+
+/**
+ * Fills in the graph window overhang, which is the area outside the graph
+ * bounds that may become visible momentarily due to scroll elasticity.
+ */
+void MacStatsPianoRoll::
+handle_draw_graph_overhang(CGContextRef ctx, NSRect rect) {
+  CGContextSetFillColorWithColor(ctx, _background_color);
+  CGContextFillRect(ctx, rect);
+
+  draw_guide_bars(ctx, rect.origin.y, rect.size.height);
+}
+
+/**
+ * Fills in the scale area.
+ */
+void MacStatsPianoRoll::
+handle_draw_scale_area(CGContextRef ctx, NSRect rect) {
+/*
+  CGContextSetRGBStrokeColor(ctx, rgb_dark_gray[0], rgb_dark_gray[1], rgb_dark_gray[2], 1.0);
+  CGContextSetStrokeColor(ctx, rgb_dark_gray);
+  CGContextBeginPath(ctx);
+  CGContextMoveToPoint(ctx, 0, header_height);
+  CGContextAddLineToPoint(ctx, get_xsize(), header_height);
+  CGContextStrokePath(ctx);*/
+
+  draw_guide_bars(ctx, rect.origin.y, rect.size.height);
+  draw_guide_labels(ctx);
+}
+
+/**
+ * Returns the collector index associated with the indicated vertical row, or
+ * -1.
+ */
+int MacStatsPianoRoll::
+get_collector_under_pixel(int xpoint, int ypoint) const {
+  if (_label_stack.get_num_labels() == 0) {
+    return -1;
+  }
+
+  // Assume all of the labels are the same height.
+  int origin = _label_stack.get_label_y(0, _graph_view);
+  int height = _label_stack.get_label_height(0);
+  int row = (origin - ypoint) / height;
+  if (row >= 0 && row < _label_stack.get_num_labels()) {
+    return _label_stack.get_label_collector_index(row);
+  } else  {
+    return -1;
+  }
+}
+
+/**
+ * Resets the list of labels.
+ */
+void MacStatsPianoRoll::
+update_labels() {
+  _label_stack.clear_labels();
+  for (int i = 0; i < get_num_labels(); ++i) {
+    _label_stack.add_label(MacStatsGraph::_monitor, this,
+         _thread_index,
+         get_label_collector(i), true);
+  }
+  _labels_changed = false;
+}
+
+/**
+ *
+ */
+void MacStatsPianoRoll::
+draw_guide_bars(CGContextRef ctx, int y, int height) {
+  CGContextSetStrokeColorWithColor(ctx, [NSColor gridColor].CGColor);
+
+  int num_guide_bars = get_num_guide_bars();
+  for (int i = 0; i < num_guide_bars; ++i) {
+    draw_guide_bar(ctx, get_guide_bar(i), y, height);
+  }
+
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (int i = 0; i < num_user_guide_bars; ++i) {
+    draw_guide_bar(ctx, get_user_guide_bar(i), y, height);
+  }
+}
+
+/**
+ * Draws the line for the indicated guide bar on the graph.
+ */
+void MacStatsPianoRoll::
+draw_guide_bar(CGContextRef ctx, const PStatGraph::GuideBar &bar,
+               int y, int height) {
+  int x = height_to_pixel(bar._height);
+
+  if (x > 0 && x < get_xsize() - 1) {
+    // Only draw it if it's not too close to the top.
+    /*switch (bar._style) {
+    case GBS_target:
+      CGContextSetRGBStrokeColor(ctx, rgb_light_gray[0], rgb_light_gray[1], rgb_light_gray[2], 1.0);
+      break;
+
+    case GBS_user:
+      CGContextSetRGBStrokeColor(ctx, rgb_user_guide_bar[0], rgb_user_guide_bar[1], rgb_user_guide_bar[2], 1.0);
+      break;
+
+    default:
+      CGContextSetRGBStrokeColor(ctx, rgb_dark_gray[0], rgb_dark_gray[1], rgb_dark_gray[2], 1.0);
+      break;
+    }*/
+    CGContextBeginPath(ctx);
+    CGContextMoveToPoint(ctx, x, y);
+    CGContextAddLineToPoint(ctx, x, y + height);
+    CGContextStrokePath(ctx);
+  }
+}
+
+/**
+ * This is called during the servicing of the draw event.
+ */
+void MacStatsPianoRoll::
+draw_guide_labels(CGContextRef ctx) {
+  int i;
+  int num_guide_bars = get_num_guide_bars();
+  for (i = 0; i < num_guide_bars; ++i) {
+    draw_guide_label(ctx, get_guide_bar(i));
+  }
+
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (i = 0; i < num_user_guide_bars; ++i) {
+    draw_guide_label(ctx, get_user_guide_bar(i));
+  }
+}
+
+/**
+ * Draws the text for the indicated guide bar label at the top of the graph.
+ */
+void MacStatsPianoRoll::
+draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar) {
+  NSColor *color;
+  if (@available(macOS 11.0, *)) {
+    color = [NSColor tertiaryLabelColor];
+  } else {
+    // Otherwise it's hard to see on the dark titlebars
+    color = [NSColor windowFrameTextColor];
+  }
+  /*
+  switch (bar._style) {
+  case GBS_target:
+    color = [NSColor colorWithDeviceRed:rgb_light_gray[0] green:rgb_light_gray[1] blue:rgb_light_gray[2] alpha:1.0];
+    break;
+
+  case GBS_user:
+    color = [NSColor colorWithDeviceRed:rgb_user_guide_bar[0] green:rgb_user_guide_bar[1] blue:rgb_user_guide_bar[2] alpha:1.0];
+    break;
+
+  default:
+    color = [NSColor colorWithDeviceRed:rgb_dark_gray[0] green:rgb_dark_gray[1] blue:rgb_dark_gray[2] alpha:1.0];
+    break;
+  }*/
+
+  int x = height_to_pixel(bar._height);
+  const std::string &label = bar._label;
+
+  const CFStringRef keys[] = {
+    (__bridge CFStringRef)NSForegroundColorAttributeName,
+    (__bridge CFStringRef)NSFontAttributeName,
+  };
+  const void *values[] = {
+    color,
+    [NSFont systemFontOfSize:0.0],
+  };
+  CFDictionaryRef attribs = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+
+  CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, label.c_str(), kCFStringEncodingUTF8);
+  CFAttributedStringRef astr = CFAttributedStringCreate(kCFAllocatorDefault, str, attribs);
+  CFRelease(attribs);
+  CFRelease(str);
+
+  CTLineRef line = CTLineCreateWithAttributedString(astr);
+  CFRelease(astr);
+  CGRect bounds = CTLineGetImageBounds(line, ctx);
+  int width = bounds.size.width;
+
+  if (bar._style != GBS_user) {
+    double from_height = pixel_to_height(x - width);
+    double to_height = pixel_to_height(x + width);
+    if (find_user_guide_bar(from_height, to_height) >= 0) {
+      // Omit the label: there's a user-defined guide bar in the same space.
+      CFRelease(line);
+      return;
+    }
+  }
+
+  if (x >= 0 && x < get_xsize()) {
+    CGContextSetTextPosition(ctx, x + 6, 6);
+    CTLineDraw(line, ctx);
+  }
+
+  CFRelease(line);
+}

+ 41 - 0
pandatool/src/mac-stats/macStatsScaleArea.h

@@ -0,0 +1,41 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsScaleArea.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSSCALEAREA_H
+#define MACSTATSSCALEAREA_H
+
+#import <Cocoa/Cocoa.h>
+
+class MacStatsGraph;
+
+@interface MacStatsScaleArea : NSView {
+  @private
+    MacStatsGraph *_graph;
+}
+
+- (id)initWithGraph:(MacStatsGraph *)graph frame:(NSRect)rect;
+- (void)drawRect:(NSRect)dirtyRect;
+
+@end
+
+@interface MacStatsScaleAreaController : NSTitlebarAccessoryViewController {
+  @private
+    MacStatsGraph *_graph;
+}
+
+- (id)initWithGraph:(MacStatsGraph *)graph;
+- (void)loadView;
+
+@end
+
+#endif

+ 50 - 0
pandatool/src/mac-stats/macStatsScaleArea.mm

@@ -0,0 +1,50 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsScaleArea.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsScaleArea.h"
+#include "macStatsGraph.h"
+#include "macStatsStripChart.h"
+
+@implementation MacStatsScaleArea
+
+- (id)initWithGraph:(MacStatsGraph *)graph frame:(NSRect)rect {
+  if (self = [super initWithFrame:rect]) {
+    _graph = graph;
+
+    [self addTrackingArea:[[NSTrackingArea alloc] initWithRect:NSZeroRect options:(NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect) owner:self userInfo:nil]];
+  }
+
+  return self;
+}
+
+- (void)drawRect:(NSRect)dirtyRect {
+  _graph->handle_draw_scale_area([NSGraphicsContext currentContext].CGContext, dirtyRect);
+}
+
+@end
+
+@implementation MacStatsScaleAreaController
+
+- (id)initWithGraph:(MacStatsGraph *)graph {
+  if (self = [super init]) {
+    _graph = graph;
+  }
+
+  return self;
+}
+
+- (void)loadView {
+  self.view = [[MacStatsScaleArea alloc] initWithGraph:_graph frame:NSMakeRect(0, 0, 100, 100)];
+}
+
+@end

+ 82 - 0
pandatool/src/mac-stats/macStatsServer.h

@@ -0,0 +1,82 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsServer.h
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#ifndef MACSTATSSERVER_H
+#define MACSTATSSERVER_H
+
+#include "pandatoolbase.h"
+#include "programBase.h"
+#include "pStatServer.h"
+#include "macStatsMonitor.h"
+#include "macStatsAppDelegate.h"
+
+#include <Cocoa/Cocoa.h>
+
+/**
+ * The class that owns the main loop, waiting for client connections.
+ */
+class MacStatsServer : public PStatServer, public ProgramBase {
+public:
+  MacStatsServer();
+
+  MacStatsMonitor *get_monitor() { return _monitor; }
+
+  void run(int argc, char *argv[]);
+
+protected:
+  virtual bool handle_args(Args &args) override;
+
+  virtual PStatMonitor *make_monitor(const NetAddress &address) override;
+  virtual void lost_connection(PStatMonitor *monitor) override;
+
+public:
+  bool new_session();
+  bool open_session(const Filename &fn);
+  bool open_session();
+  bool open_last_session();
+  bool save_session();
+  bool export_session();
+  bool close_session();
+
+  void set_show_status_item(bool show);
+  void set_appearance(NSString *name);
+  void set_time_units(int unit_mask);
+
+private:
+  void create_app();
+  void setup_session_menu();
+
+private:
+  PT(MacStatsMonitor) _monitor;
+
+  Filename _last_session;
+  Filename _save_filename;
+
+  int _port = -1;
+  NSApplication *_app = nil;
+  NSUserNotification *_listening_notification = nil;
+  NSMenu *_main_menu = nil;
+  NSMenuItem *_show_status_item_menu_item = nil;
+  NSMenuItem *_appearance_system_menu_item = nil;
+  NSMenuItem *_appearance_aqua_menu_item = nil;
+  NSMenuItem *_appearance_dark_aqua_menu_item = nil;
+  NSMenuItem *_units_ms_menu_item = nil;
+  NSMenuItem *_units_hz_menu_item = nil;
+  NSMenuItem *_new_session_menu_item = nil;
+  NSMenuItem *_open_last_session_menu_item = nil;
+  NSMenuItem *_save_session_menu_item = nil;
+  NSMenuItem *_close_session_menu_item = nil;
+  NSMenuItem *_export_session_menu_item = nil;
+};
+
+#endif

+ 732 - 0
pandatool/src/mac-stats/macStatsServer.mm

@@ -0,0 +1,732 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsServer.mm
+ * @author rdb
+ * @date 2023-08-17
+ */
+
+#include "macStatsServer.h"
+#include "macStatsMonitor.h"
+#include "macStatsAppDelegate.h"
+#include "pandaVersion.h"
+#include "pStatGraph.h"
+#include "config_pstatclient.h"
+
+#include <unistd.h>
+
+/**
+ *
+ */
+MacStatsServer::
+MacStatsServer() : _port(pstats_port) {
+  set_program_brief("macOS PStats client");
+  set_program_description
+    ("This is a GUI-based PStats server that listens on a TCP port for a "
+     "connection from a PStatClient in a Panda3D application.  It offers "
+     "various graphs for showing the timing information sent by the client."
+     "\n\n"
+     "The full documentation is available online:\n  "
+#ifdef HAVE_PYTHON
+     "https://docs.panda3d.org/" PANDA_ABI_VERSION_STR "/python/optimization/pstats"
+#else
+     "https://docs.panda3d.org/" PANDA_ABI_VERSION_STR "/cpp/optimization/pstats"
+#endif
+     "");
+
+  add_option
+    ("p", "port", 0,
+     "Specify the TCP port to listen for connections on.  By default, this "
+     "is taken from the pstats-port Config variable.",
+     &ProgramBase::dispatch_int, nullptr, &_port);
+
+  add_runline("[-p 5185]");
+  add_runline("session.pstats");
+
+  _last_session = Filename::expand_from(
+    "$HOME/Library/Caches/Panda3D-" PANDA_ABI_VERSION_STR "/last-session.pstats");
+  _last_session.set_binary();
+
+  create_app();
+}
+
+/**
+ * Runs the server.
+ */
+void MacStatsServer::
+run(int argc, char *argv[]) {
+  if (parse_command_line(argc, argv, isatty(STDERR_FILENO)) == ProgramBase::EC_failure) {
+    NSAlert *alert = [[NSAlert alloc] init];
+    alert.messageText = @"PStats Error";
+    alert.informativeText = @"Failed to parse command-line options.";
+    alert.alertStyle = NSCriticalAlertStyle;
+    [alert runModal];
+    [alert release];
+    return;
+  }
+
+  [_app run];
+}
+
+/**
+ * Does something with the additional arguments on the command line (after all
+ * the -options have been parsed).  Returns true if the arguments are good,
+ * false otherwise.
+ */
+bool MacStatsServer::
+handle_args(ProgramBase::Args &args) {
+  if (args.empty()) {
+    // applicationDidFinishLaunching will call new_session().
+    return true;
+  }
+  else if (args.size() == 1) {
+    // Handled by application:openFile:
+    return true;
+  }
+  else {
+    nout << "At most one filename may be specified on the command-line.\n";
+    return false;
+  }
+}
+
+/**
+ *
+ */
+PStatMonitor *MacStatsServer::
+make_monitor(const NetAddress &address) {
+  // Enable the "New Session", "Save Session" and "Close Session" menu items.
+  _new_session_menu_item.enabled = YES;
+  _save_session_menu_item.enabled = YES;
+  _close_session_menu_item.enabled = YES;
+  _export_session_menu_item.enabled = YES;
+
+  NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+  if (_listening_notification != nil) {
+    [center removeDeliveredNotification:_listening_notification];
+    [_listening_notification release];
+    _listening_notification = nil;
+  }
+
+  _monitor = new MacStatsMonitor(this);
+  return _monitor;
+}
+
+/**
+ * Called when connection has been lost.
+ */
+void MacStatsServer::
+lost_connection(PStatMonitor *monitor) {
+  if (_monitor != nullptr && !_monitor->_have_data) {
+    // We didn't have any data yet.  Just silently restart the session.
+    _monitor->close();
+    _monitor = nullptr;
+    if (new_session()) {
+      return;
+    }
+  } else {
+    // Store a backup now, in case PStats crashes or something.
+    _last_session.make_dir();
+    if (monitor->write(_last_session)) {
+      nout << "Wrote to " << _last_session << "\n";
+    } else {
+      nout << "Failed to write to " << _last_session << "\n";
+    }
+  }
+
+  stop_listening();
+}
+
+/**
+ * Starts a new session.
+ */
+bool MacStatsServer::
+new_session() {
+  if (!close_session()) {
+    return false;
+  }
+
+  if (listen(_port)) {
+    NSUserNotification *notification = [[NSUserNotification alloc] init];
+    notification.title = @"PStats Server";
+    notification.informativeText = [NSString stringWithFormat:@"Waiting for client to connect on port %d\u2026", _port];
+
+    // Quick way to do something else if the user prefers.
+    notification.additionalActions = @[
+      [NSUserNotificationAction actionWithIdentifier:@"open" title:@"Open Session\u2026"],
+      [NSUserNotificationAction actionWithIdentifier:@"openLast" title:@"Open Last Session"],
+      [NSUserNotificationAction actionWithIdentifier:@"quit" title:@"Quit PStats"]
+    ];
+
+    NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+    [center deliverNotification:notification];
+    _listening_notification = notification;
+
+    _new_session_menu_item.enabled = NO;
+    _save_session_menu_item.enabled = NO;
+    _close_session_menu_item.enabled = YES;
+    _export_session_menu_item.enabled = NO;
+
+    return true;
+  }
+
+  NSAlert *alert = [[NSAlert alloc] init];
+  alert.messageText = @"PStats Error";
+  alert.informativeText = [NSString stringWithFormat:@"Unable to open port %d.  Try specifying a different port number using pstats-port in your Config file or the -p option on the command-line.", _port];
+  alert.alertStyle = NSCriticalAlertStyle;
+  [alert runModal];
+  [alert release];
+
+  return false;
+}
+
+/**
+ * Opens a session with the given filename.
+ */
+bool MacStatsServer::
+open_session(const Filename &fn) {
+  if (!close_session()) {
+    return false;
+  }
+
+  MacStatsMonitor *monitor = new MacStatsMonitor(this);
+  if (!monitor->read(fn)) {
+    delete monitor;
+    return false;
+  }
+
+  _save_filename = fn;
+  _new_session_menu_item.enabled = YES;
+  _save_session_menu_item.enabled = YES;
+  _close_session_menu_item.enabled = YES;
+  _export_session_menu_item.enabled = YES;
+
+  _monitor = monitor;
+
+  // If the file contained no graphs, open the default graphs.
+  if (monitor->_graphs.empty()) {
+    monitor->open_default_graphs();
+  }
+
+  return true;
+}
+
+/**
+ * Offers to open an existing session.
+ */
+bool MacStatsServer::
+open_session() {
+  if (!close_session()) {
+    return false;
+  }
+
+  NSOpenPanel *panel = [NSOpenPanel openPanel];
+  panel.title = @"Open Session";
+
+  if ([panel runModal] == NSModalResponseOK) {
+    NSString *path = [[panel.URLs firstObject] path];
+    Filename fn([path UTF8String]);
+    fn.set_binary();
+    if (open_session(fn)) {
+      return true;
+    }
+
+    NSAlert *alert = [[NSAlert alloc] init];
+    alert.messageText = @"PStats Error";
+    alert.informativeText = [NSString stringWithFormat:@"Failed to load session file: %s", fn.c_str()];
+    alert.alertStyle = NSCriticalAlertStyle;
+    [alert runModal];
+    [alert release];
+  }
+
+  return false;
+}
+
+/**
+ * Opens the last session, if any.
+ */
+bool MacStatsServer::
+open_last_session() {
+  if (open_session(_last_session)) {
+    return true;
+  }
+
+  NSAlert *alert = [[NSAlert alloc] init];
+  alert.messageText = @"PStats Error";
+  alert.informativeText = [NSString stringWithFormat:@"Failed to load session file: %s", _last_session.c_str()];
+  alert.alertStyle = NSCriticalAlertStyle;
+  [alert runModal];
+  [alert release];
+
+  return false;
+}
+
+/**
+ * Offers to save the current session.
+ */
+bool MacStatsServer::
+save_session() {
+  nassertr_always(_monitor != nullptr, true);
+
+  NSSavePanel *panel = [NSSavePanel savePanel];
+  panel.title = @"Save Session";
+
+  if (_save_filename.empty()) {
+    panel.nameFieldStringValue = @"session.pstats";
+  } else {
+    std::string dirname = _save_filename.get_dirname();
+    std::string basename = _save_filename.get_basename();
+    panel.nameFieldStringValue = [NSString stringWithUTF8String:basename.c_str()];
+    panel.directoryURL = [NSURL fileURLWithPath:[NSString stringWithUTF8String:dirname.c_str()]];
+  }
+
+  if ([panel runModal] == NSModalResponseOK) {
+    NSString *path = [panel.URL path];
+    Filename fn([path UTF8String]);
+    fn.set_binary();
+
+    if (!_monitor->write(fn)) {
+      NSAlert *alert = [[NSAlert alloc] init];
+      alert.messageText = @"PStats Error";
+      alert.informativeText = [NSString stringWithFormat:@"Failed to save session file: %s", fn.c_str()];
+      alert.alertStyle = NSCriticalAlertStyle;
+      [alert runModal];
+      [alert release];
+
+      return false;
+    }
+    _save_filename = fn;
+    _monitor->get_client_data()->clear_dirty();
+    return true;
+  }
+
+  return false;
+}
+
+/**
+ * Offers to export the current session as a JSON file.
+ */
+bool MacStatsServer::
+export_session() {
+  nassertr_always(_monitor != nullptr, true);
+
+  NSSavePanel *panel = [NSSavePanel savePanel];
+  panel.title = @"Export Session";
+  panel.nameFieldStringValue = @"session.json";
+
+  if ([panel runModal] == NSModalResponseOK) {
+    NSString *path = [panel.URL path];
+    Filename fn([path UTF8String]);
+    fn.set_binary();
+
+    std::ofstream stream;
+    if (!fn.open_write(stream)) {
+      NSAlert *alert = [[NSAlert alloc] init];
+      alert.messageText = @"PStats Error";
+      alert.informativeText = [NSString stringWithFormat:@"Failed to open file for export: %s", fn.c_str()];
+      alert.alertStyle = NSCriticalAlertStyle;
+      [alert runModal];
+      [alert release];
+
+      return false;
+    }
+
+    int pid = _monitor->get_client_pid();
+    _monitor->get_client_data()->write_json(stream, std::max(0, pid));
+    stream.close();
+    return true;
+  }
+
+  return false;
+}
+
+/**
+ * Closes the current session.
+ */
+bool MacStatsServer::
+close_session() {
+  bool wrote_last_session = false;
+
+  if (_monitor != nullptr) {
+    const PStatClientData *client_data = _monitor->get_client_data();
+    if (client_data != nullptr && client_data->is_dirty()) {
+      if (!_monitor->has_read_filename()) {
+        _last_session.make_dir();
+        if (_monitor->write(_last_session)) {
+          nout << "Wrote to " << _last_session << "\n";
+          wrote_last_session = true;
+        }
+        else {
+          nout << "Failed to write to " << _last_session << "\n";
+        }
+      }
+
+      // Make sure the alert goes on top.
+      [_app activateIgnoringOtherApps:YES];
+
+      NSAlert *alert = [[NSAlert alloc] init];
+      alert.messageText = @"Unsaved Data";
+      alert.informativeText = @"Would you like to save the currently open session?";
+      [alert addButtonWithTitle:@"Save\u2026"];
+      [alert addButtonWithTitle:@"Don't Save"];
+      [alert addButtonWithTitle:@"Cancel"];
+      int response = [alert runModal];
+      [alert release];
+
+      if ((response != 1000 && response != 1001) ||
+          (response == 1000 && !save_session())) {
+        return false;
+      }
+    }
+
+    _monitor->close();
+    _monitor = nullptr;
+  }
+
+  _save_filename = Filename();
+  stop_listening();
+
+  if (_listening_notification != nil) {
+    NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
+    [center removeDeliveredNotification:_listening_notification];
+    [_listening_notification release];
+    _listening_notification = nil;
+  }
+
+  _new_session_menu_item.enabled = YES;
+  if (wrote_last_session) {
+    _open_last_session_menu_item.enabled = YES;
+  }
+  _save_session_menu_item.enabled = NO;
+  _close_session_menu_item.enabled = NO;
+  _export_session_menu_item.enabled = NO;
+  return true;
+}
+
+/**
+ * Enables the frame rate label on the right end of the menu bar.  This is
+ * used as a text label to display the main thread's frame rate to the user,
+ * although it is implemented as a right-justified toplevel menu item that
+ * doesn't open to anything.
+ */
+void MacStatsServer::
+set_show_status_item(bool show) {
+  if (_monitor != nullptr) {
+    _monitor->set_show_status_item(show);
+  }
+
+  _show_status_item_menu_item.state = show ? NSOnState : NSOffState;
+}
+
+/**
+ *
+ */
+void MacStatsServer::
+set_appearance(NSString *name) {
+  if (@available(macOS 10.14, *)) {
+    if (name == nil || name.length == 0) {
+      [_app setAppearance:nil];
+      _appearance_system_menu_item.state = NSOnState;
+    } else {
+      [_app setAppearance:[NSAppearance appearanceNamed:name]];
+      _appearance_system_menu_item.state = NSOffState;
+    }
+
+    _appearance_aqua_menu_item.state = (name != nil && [name isEqual:@"NSAppearanceNameAqua"]) ? NSOnState : NSOffState;
+    _appearance_dark_aqua_menu_item.state = (name != nil && [name isEqual:@"NSAppearanceNameDarkAqua"]) ? NSOnState : NSOffState;
+  }
+}
+
+/**
+ * Called when the user selects a new time units from the monitor pulldown
+ * menu, this should adjust the units for all graphs to the indicated mask if
+ * it is a time-based graph.
+ */
+void MacStatsServer::
+set_time_units(int unit_mask) {
+  if (_monitor != nullptr) {
+    _monitor->set_time_units(unit_mask);
+  }
+
+  _units_ms_menu_item.state = (unit_mask & PStatGraph::GBU_ms) ? NSOnState : NSOffState;
+  _units_hz_menu_item.state = (unit_mask & PStatGraph::GBU_hz) ? NSOnState : NSOffState;
+}
+
+/**
+ * Creates the menu bar for this monitor.
+ */
+void MacStatsServer::
+create_app() {
+  _app = [NSApplication sharedApplication];
+
+  MacStatsAppDelegate *delegate = [[MacStatsAppDelegate alloc] initWithServer:this];
+  [_app setDelegate:delegate];
+  [_app setActivationPolicy:NSApplicationActivationPolicyRegular];
+
+  _main_menu = [[NSMenu alloc] init];
+
+  NSMenu *app_menu = [[NSMenu alloc] init];
+
+  NSMenu *settings_menu = [[NSMenu alloc] init];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleToggleSettingsBool:);
+    item.representedObject = @"ShowStatusItem";
+    item.title = @"Show in Status Bar";
+    item.state = NSOnState;
+    [settings_menu addItem:item];
+
+    _show_status_item_menu_item = item;
+    [item release];
+  }
+
+  NSMenu *units_menu;
+  if (@available(macOS 14.0, *)) {
+    // On Sonnoma, use a section with header.
+    [settings_menu addItem:[NSMenuItem separatorItem]];
+    [settings_menu addItem:[NSMenuItem sectionHeaderWithTitle:@"Time Units"]];
+    units_menu = settings_menu;
+  } else {
+    // On older versions, create a sub-menu.
+    units_menu = [[NSMenu alloc] init];
+    units_menu.autoenablesItems = NO;
+
+    NSMenuItem *units_item = [[NSMenuItem alloc] init];
+    units_item.title = @"Time Units";
+
+    [settings_menu addItem:units_item];
+    [settings_menu setSubmenu:units_menu forItem:units_item];
+    [units_item release];
+    [units_menu release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSettingsInteger:);
+    item.representedObject = @"TimeUnits";
+    item.tag = PStatGraph::GBU_ms;
+    item.title = @"ms";
+    item.state = NSOnState;
+    [units_menu addItem:item];
+
+    _units_ms_menu_item = item;
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSettingsInteger:);
+    item.representedObject = @"TimeUnits";
+    item.tag = PStatGraph::GBU_hz;
+    item.title = @"Hz";
+    item.state = NSOffState;
+    [units_menu addItem:item];
+
+    _units_hz_menu_item = item;
+    [item release];
+  }
+
+  // Dark mode available from Mojave.
+  if (@available(macOS 10.14, *)) {
+    NSMenu *appearance_menu;
+    if (@available(macOS 14.0, *)) {
+      // On Sonnoma, use a section with header.
+      [settings_menu addItem:[NSMenuItem separatorItem]];
+      [settings_menu addItem:[NSMenuItem sectionHeaderWithTitle:@"Appearance"]];
+      appearance_menu = settings_menu;
+    } else {
+      // On older versions, create a sub-menu.
+      appearance_menu = [[NSMenu alloc] init];
+      appearance_menu.autoenablesItems = NO;
+
+      NSMenuItem *appearance_item = [[NSMenuItem alloc] init];
+      appearance_item.title = @"Appearance";
+
+      [settings_menu addItem:appearance_item];
+      [settings_menu setSubmenu:appearance_menu forItem:appearance_item];
+      [appearance_item release];
+      [appearance_menu release];
+    }
+
+    {
+      NSMenuItem *item = [[NSMenuItem alloc] init];
+      item.action = @selector(handleSettingsAppearance:);
+      item.representedObject = @"";
+      item.title = @"System";
+      item.state = NSOnState;
+      [appearance_menu addItem:item];
+      _appearance_system_menu_item = item;
+      [item release];
+    }
+
+    {
+      NSMenuItem *item = [[NSMenuItem alloc] init];
+      item.action = @selector(handleSettingsAppearance:);
+      item.representedObject = @"NSAppearanceNameAqua";
+      item.title = @"Aqua";
+      item.state = NSOffState;
+      [appearance_menu addItem:item];
+      _appearance_aqua_menu_item = item;
+      [item release];
+    }
+
+    {
+      NSMenuItem *item = [[NSMenuItem alloc] init];
+      item.action = @selector(handleSettingsAppearance:);
+      item.representedObject = @"NSAppearanceNameDarkAqua";
+      item.title = @"Dark Aqua";
+      item.state = NSOffState;
+      [appearance_menu addItem:item];
+      _appearance_dark_aqua_menu_item = item;
+      [item release];
+    }
+  }
+
+  NSMenuItem *settings_item = [[NSMenuItem alloc] init];
+  settings_item.title = @"Settings";
+  [app_menu addItem:settings_item];
+  [app_menu setSubmenu:settings_menu forItem:settings_item];
+  [settings_item release];
+  [settings_menu release];
+
+  [app_menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(hide:);
+    item.keyEquivalent = @"h";
+    item.title = @"Hide PStats";
+    [app_menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(hideOtherApplications:);
+    item.keyEquivalent = @"h";
+    item.keyEquivalentModifierMask |= NSAlternateKeyMask;
+    item.title = @"Hide Others";
+    [app_menu addItem:item];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(unhideAllApplications:);
+    item.title = @"Show All";
+    [app_menu addItem:item];
+    [item release];
+  }
+
+  [app_menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(terminate:);
+    item.keyEquivalent = @"q";
+    item.title = @"Quit PStats";
+    [app_menu addItem:item];
+    [item release];
+  }
+
+  NSMenuItem *app_menu_item = [[NSMenuItem alloc] init];
+  [_main_menu addItem:app_menu_item];
+  [_main_menu setSubmenu:app_menu forItem:app_menu_item];
+  [app_menu_item release];
+  [app_menu release];
+
+  setup_session_menu();
+
+  [_app setMainMenu:_main_menu];
+  [_main_menu release];
+
+  //[_app activateIgnoringOtherApps:YES];
+  //[_app finishLaunching];
+
+  set_time_units(PStatGraph::GBU_ms);
+}
+
+/**
+ * Creates the "Session" pulldown menu.
+ */
+void MacStatsServer::
+setup_session_menu() {
+  NSMenu *submenu = [[NSMenu alloc] init];
+  submenu.title = @"Session";
+  submenu.autoenablesItems = NO;
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleNewSession:);
+    item.keyEquivalent = @"n";
+    item.title = @"New Session";
+    [submenu addItem:item];
+
+    _new_session_menu_item = item;
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleOpenSession:);
+    item.keyEquivalent = @"o";
+    item.title = @"Open Session\u2026";
+    [submenu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleOpenLastSession:);
+    item.title = @"Open Last Session";
+    item.enabled = _last_session.exists();
+    [submenu addItem:item];
+
+    _open_last_session_menu_item = item;
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleSaveSession:);
+    item.keyEquivalent = @"s";
+    item.title = @"Save Session\u2026";
+    item.enabled = NO;
+    [submenu addItem:item];
+
+    _save_session_menu_item = item;
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleCloseSession:);
+    item.keyEquivalent = @"w";
+    item.title = @"Close Session";
+    item.enabled = NO;
+    [submenu addItem:item];
+
+    _close_session_menu_item = item;
+    [item release];
+  }
+
+  [submenu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] init];
+    item.action = @selector(handleExportSession:);
+    item.title = @"Export as JSON\u2026";
+    item.enabled = NO;
+    [submenu addItem:item];
+
+    _export_session_menu_item = item;
+    [item release];
+  }
+
+  NSMenuItem *item = [[NSMenuItem alloc] init];
+  [_main_menu addItem:item];
+  [_main_menu setSubmenu:submenu forItem:item];
+}

+ 90 - 0
pandatool/src/mac-stats/macStatsStripChart.h

@@ -0,0 +1,90 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsStripChart.h
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#ifndef MACSTATSSTRIPCHART_H
+#define MACSTATSSTRIPCHART_H
+
+#include "macStatsGraph.h"
+#include "pStatStripChart.h"
+#include "macStatsChartMenuDelegate.h"
+
+class MacStatsMonitor;
+
+/**
+ * A window that draws a strip chart, given a view.
+ */
+class MacStatsStripChart final : public PStatStripChart, public MacStatsGraph {
+public:
+  MacStatsStripChart(MacStatsMonitor *monitor,
+                     int thread_index, int collector_index, bool show_level);
+  virtual ~MacStatsStripChart();
+
+  virtual void new_collector(int collector_index);
+  virtual void new_data(int thread_index, int frame_number);
+  virtual void force_redraw();
+  virtual void changed_graph_size(int graph_xsize, int graph_ysize);
+
+  virtual void set_time_units(int unit_mask);
+  virtual void set_scroll_speed(double scroll_speed);
+  virtual void on_click_label(int collector_index);
+  virtual NSMenu *get_label_menu(int collector_index) const;
+  virtual std::string get_label_tooltip(int collector_index) const;
+  void set_vertical_scale(double value_height);
+  void set_auto_vertical_scale();
+
+protected:
+  virtual void update_labels();
+
+  virtual void clear_region();
+  virtual void draw_slice(int x, int w,
+                          const PStatStripChart::FrameData &fdata);
+  virtual void draw_empty(int x, int w);
+  virtual void draw_cursor(int x);
+  virtual void end_draw(int from_x, int to_x);
+
+  virtual bool get_window_state(int &x, int &y, int &width, int &height,
+                                bool &maximized, bool &minimized) const;
+  virtual void set_window_state(int x, int y, int width, int height,
+                                bool maximized, bool minimized);
+
+  virtual NSMenu *get_graph_menu(int mouse_x, int mouse_y) const;
+  virtual std::string get_graph_tooltip(int mouse_x, int mouse_y) const;
+  virtual DragMode consider_drag_start(int graph_x, int graph_y);
+  virtual void set_drag_mode(DragMode drag_mode);
+
+  virtual void handle_button_press(int graph_x, int graph_y,
+                                   bool double_click, int button);
+  virtual void handle_button_release(int graph_x, int graph_y);
+  virtual void handle_motion(int graph_x, int graph_y);
+  virtual void handle_leave();
+  virtual void handle_magnify(int graph_x, int graph_y, double scale);
+  virtual void handle_draw_graph(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_scale_area(CGContextRef ctx, NSRect rect);
+  virtual void handle_back();
+
+private:
+  void draw_guide_bar(CGContextRef ctx, int from_x, int to_x,
+                      const PStatGraph::GuideBar &bar);
+  void draw_guide_labels(CGContextRef ctx);
+  int draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar, int last_y);
+
+private:
+  MacStatsChartMenuDelegate *_menu_delegate;
+  //NSButton *_smooth_checkbox;
+  //NSTextField *_total_label;
+  NSToolbarItem *_total_item;
+
+  std::vector<int> _back_stack;
+};
+
+#endif

+ 964 - 0
pandatool/src/mac-stats/macStatsStripChart.mm

@@ -0,0 +1,964 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsStripChart.mm
+ * @author rdb
+ * @date 2023-08-18
+ */
+
+#include "macStatsStripChart.h"
+#include "macStatsMonitor.h"
+#include "macStatsScaleArea.h"
+#include "pStatCollectorDef.h"
+#include "cocoa_compat.h"
+
+@interface MacStatsStripChartViewController : MacStatsGraphViewController
+@end
+
+static const int default_strip_chart_width = 400;
+static const int default_strip_chart_height = 200;
+
+static const int minimum_strip_chart_sidebar_width = 116;
+static const int default_strip_chart_sidebar_width = 116;
+
+/**
+ *
+ */
+MacStatsStripChart::
+MacStatsStripChart(MacStatsMonitor *monitor, int thread_index,
+                   int collector_index, bool show_level) :
+  PStatStripChart(monitor, thread_index, collector_index, show_level, 0, 0),
+  MacStatsGraph(monitor, [MacStatsStripChartViewController alloc])
+{
+  // Used for popup menus.
+  _menu_delegate = [[MacStatsChartMenuDelegate alloc] initWithMonitor:monitor threadIndex:thread_index];
+
+  // Set the initial size of the graph.
+  int height = default_strip_chart_height + _window.frame.size.height - _window.contentLayoutRect.size.height;
+  _graph_view.frame = NSMakeRect(0, 0, default_strip_chart_width, height);
+  _graph_view_controller.view.frame = NSMakeRect(0, 0, default_strip_chart_width, height);
+
+  // It's put inside a scroll view that tracks the main scroll view.
+  NSScrollView *scroll_view = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 0, default_strip_chart_sidebar_width, 0)];
+  scroll_view.documentView = _label_stack.get_view();
+  scroll_view.drawsBackground = NO;
+  scroll_view.automaticallyAdjustsContentInsets = YES;
+  scroll_view.hasHorizontalScroller = NO;
+  scroll_view.hasVerticalScroller = NO;
+
+  NSViewController *sidebar_controller = [[NSViewController alloc] init];
+  sidebar_controller.view = scroll_view;
+
+  // Add a view to the right of the graph, to display all of the scale units.
+  // Calculate how wide it should be to display a typical label.
+  CGFloat width = [NSTextField labelWithString:@"999 ms"].frame.size.width + 8;
+  _scale_area = [[MacStatsScaleArea alloc] initWithGraph:this frame:NSMakeRect(0, 0, width, 0)];
+  _scale_area.autoresizingMask = NSViewHeightSizable;
+
+  NSViewController *scale_area_controller = [[NSViewController alloc] init];
+  scale_area_controller.view = _scale_area;
+
+  NSSplitViewController *svc = [[NSSplitViewController alloc] init];
+  [svc addSplitViewItem:[NSSplitViewItem sidebarWithViewController:sidebar_controller]];
+  [svc addSplitViewItem:[NSSplitViewItem splitViewItemWithViewController:_graph_view_controller]];
+  [svc addSplitViewItem:[NSSplitViewItem splitViewItemWithViewController:scale_area_controller]];
+
+  svc.splitViewItems[0].minimumThickness = minimum_strip_chart_sidebar_width;
+  svc.splitViewItems[2].minimumThickness = width;
+  svc.splitViewItems[2].maximumThickness = width;
+
+  NSSplitView *split_view = svc.splitView;
+  split_view.vertical = YES;
+  split_view.dividerStyle = NSSplitViewDividerStyleThin;
+  split_view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
+  _split_view = split_view;
+
+  // When sidebar collapses, show a sidebar icon in the menu bar
+  NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+  [center addObserver:_graph_view_controller
+             selector:@selector(handleSplitViewResize:)
+                 name:NSSplitViewDidResizeSubviewsNotification
+               object:split_view];
+
+  _window.contentViewController = svc;
+
+  _graph_view_controller.backToolbarItemVisible = NO;
+
+  [svc release];
+  [sidebar_controller release];
+
+  _total_item = nil;
+  if (@available(macOS 11.0, *)) {
+    NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@""];
+    toolbar.delegate = _graph_view_controller;
+    toolbar.displayMode = NSToolbarDisplayModeIconOnly;
+    _window.toolbar = toolbar;
+    [_window setToolbarStyle:NSWindowToolbarStyleUnifiedCompact];
+
+    for (NSToolbarItem *item in toolbar.items) {
+      if ([item.itemIdentifier isEqual:@"total"]) {
+        _total_item = item;
+        break;
+      }
+    }
+    [toolbar release];
+  }
+
+  /*{
+    _smooth_checkbox = [NSButton checkboxWithTitle:@"Smooth"
+                                            target:_graph_view_controller
+                                            action:@selector(handleToggleAverageMode:)];
+
+    NSStackView *stack_view = [NSStackView stackViewWithViews:@[_smooth_checkbox]];
+    stack_view.translatesAutoresizingMaskIntoConstraints = NO;
+
+    NSTitlebarAccessoryViewController *accessory_controller = [[NSTitlebarAccessoryViewController alloc] init];
+    //accessory_controller.layoutAttribute = NSLayoutAttributeLeft;
+    [accessory_controller.view addSubview:stack_view];
+    //accessory_controller.automaticallyAdjustsSize = NO;
+    [_window addTitlebarAccessoryViewController:accessory_controller];
+    [accessory_controller release];
+
+    [stack_view.leftAnchor constraintEqualToAnchor:_graph_view.leftAnchor constant:8].active = YES;
+    [stack_view.bottomAnchor constraintEqualToAnchor:((NSLayoutGuide *)_window.contentLayoutGuide).topAnchor constant:-8].active = YES;
+    //[stack_view.topAnchor constraintEqualToAnchor:svc.view.topAnchor constant:-8].active = YES;
+  }*/
+  /*{
+    _total_label = [NSTextField labelWithString:@""];
+
+    NSStackView *stack_view = [NSStackView stackViewWithViews:@[_total_label]];
+    stack_view.translatesAutoresizingMaskIntoConstraints = NO;
+
+    NSTitlebarAccessoryViewController *accessory_controller = [[NSTitlebarAccessoryViewController alloc] init];
+    accessory_controller.layoutAttribute = NSLayoutAttributeRight;
+    [accessory_controller.view addSubview:stack_view];
+    //accessory_controller.automaticallyAdjustsSize = NO;
+    [_window addTitlebarAccessoryViewController:accessory_controller];
+    [accessory_controller release];
+
+    [stack_view.rightAnchor constraintEqualToAnchor:_graph_view.rightAnchor].active = YES;
+    [stack_view.bottomAnchor constraintEqualToAnchor:((NSLayoutGuide *)_window.contentLayoutGuide).topAnchor].active = YES;
+    [stack_view.topAnchor constraintEqualToAnchor:svc.view.topAnchor].active = YES;
+  }*/
+
+  if (show_level) {
+    // If it's a level-type graph, show the appropriate units.
+    if (_unit_name.empty()) {
+      set_guide_bar_units(GBU_named);
+    } else {
+      set_guide_bar_units(GBU_named | GBU_show_units);
+    }
+  } else {
+    // If it's a time-type graph, show the ms / Hz units.
+    set_guide_bar_units(get_guide_bar_units() | GBU_show_units);
+  }
+
+  [_label_stack.get_view().widthAnchor constraintEqualToAnchor:scroll_view.widthAnchor].active = YES;
+
+  // Update window title and total label.
+  new_data(0, 0);
+
+  [_window makeKeyAndOrderFront:nil];
+}
+
+/**
+ *
+ */
+MacStatsStripChart::
+~MacStatsStripChart() {
+  [_menu_delegate release];
+}
+
+/**
+ * Called whenever a new Collector definition is received from the client.
+ */
+void MacStatsStripChart::
+new_collector(int collector_index) {
+  MacStatsGraph::new_collector(collector_index);
+}
+
+/**
+ * Called as each frame's data is made available.  There is no guarantee the
+ * frames will arrive in order, or that all of them will arrive at all.  The
+ * monitor should be prepared to accept frames received out-of-order or
+ * missing.
+ */
+void MacStatsStripChart::
+new_data(int thread_index, int frame_number) {
+  if (is_title_unknown()) {
+    std::string window_title = get_title_text();
+    if (!is_title_unknown()) {
+      _window.title = [NSString stringWithUTF8String:window_title.c_str()];
+    }
+  }
+
+  if (!_pause) {
+    update();
+
+    if (@available(macOS 10.15, *)) {
+      std::string text = get_total_text();
+      [_total_item setTitle:[NSString stringWithUTF8String:text.c_str()]];
+    }
+  }
+}
+
+/**
+ * Called when it is necessary to redraw the entire graph.
+ */
+void MacStatsStripChart::
+force_redraw() {
+  if (_ctx) {
+    PStatStripChart::force_redraw();
+  }
+
+  _scale_area.needsDisplay = YES;
+}
+
+/**
+ * Called when the user has resized the window, forcing a resize of the graph.
+ */
+void MacStatsStripChart::
+changed_graph_size(int graph_xsize, int graph_ysize) {
+  PStatStripChart::changed_size(graph_xsize, graph_ysize);
+}
+
+/**
+ * Called when the user selects a new time units from the monitor pulldown
+ * menu, this should adjust the units for the graph to the indicated mask if
+ * it is a time-based graph.
+ */
+void MacStatsStripChart::
+set_time_units(int unit_mask) {
+  int old_unit_mask = get_guide_bar_units();
+  if ((old_unit_mask & (GBU_hz | GBU_ms)) != 0) {
+    unit_mask = unit_mask & (GBU_hz | GBU_ms);
+    unit_mask |= (old_unit_mask & GBU_show_units);
+    set_guide_bar_units(unit_mask);
+
+    if (@available(macOS 10.15, *)) {
+      std::string text = get_total_text();
+      [_total_item setTitle:[NSString stringWithUTF8String:text.c_str()]];
+    }
+
+    _scale_area.needsDisplay = YES;
+  }
+}
+
+/**
+ * Called when the user selects a new scroll speed from the monitor pulldown
+ * menu, this should adjust the speed for the graph to the indicated value.
+ */
+void MacStatsStripChart::
+set_scroll_speed(double scroll_speed) {
+  // The speed factor indicates chart widths per minute.
+  if (scroll_speed != 0.0f) {
+    set_horizontal_scale(60.0f / scroll_speed);
+  }
+}
+
+/**
+ * Called when the user single-clicks on a label.
+ */
+void MacStatsStripChart::
+on_click_label(int collector_index) {
+  if (collector_index < 0) {
+    // Clicking on whitespace in the graph is the same as clicking on the top
+    // label.
+    collector_index = get_collector_index();
+  }
+
+  if (collector_index == get_collector_index()) {
+    // Clicking on the top label means to go up to the parent level.
+    if (collector_index != 0) {
+      const PStatClientData *client_data =
+        MacStatsGraph::_monitor->get_client_data();
+      if (client_data->has_collector(collector_index)) {
+        const PStatCollectorDef &def =
+          client_data->get_collector_def(collector_index);
+        if (def._parent_index == 0 && get_view().get_show_level()) {
+          // Unless the parent is "Frame", and we're not a time collector.
+        }
+        else if (def._parent_index != get_collector_index()) {
+          // If we were previously at the parent, pop it from the stack.
+          if (!_back_stack.empty() && _back_stack.back() == def._parent_index) {
+            _back_stack.pop_back();
+            if (_back_stack.empty()) {
+              _graph_view_controller.backToolbarItemVisible = NO;
+            }
+          } else {
+            if (_back_stack.empty()) {
+              _graph_view_controller.backToolbarItemVisible = YES;
+            }
+            _back_stack.push_back(get_collector_index());
+          }
+
+          set_collector_index(def._parent_index);
+        }
+      }
+    }
+  }
+  else {
+    if (_back_stack.empty()) {
+      _graph_view_controller.backToolbarItemVisible = YES;
+    }
+    _back_stack.push_back(get_collector_index());
+
+    // Clicking on any other label means to focus on that.
+    set_collector_index(collector_index);
+  }
+
+  // Update window title and total label.
+  new_data(0, 0);
+
+  _scale_area.needsDisplay = YES;
+}
+
+/**
+ * Called when the mouse right-clicks on a label, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsStripChart::
+get_label_menu(int collector_index) const {
+  NSMenu *menu = [[[NSMenu alloc] init] autorelease];
+
+  std::string label = get_label_tooltip(collector_index);
+  if (!label.empty()) {
+    if (@available(macOS 14.0, *)) {
+      [menu addItem:[NSMenuItem sectionHeaderWithTitle:[NSString stringWithUTF8String:label.c_str()]]];
+    } else {
+      NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:label.c_str()] action:nil keyEquivalent:@""];
+      item.enabled = NO;
+      [menu addItem:item];
+      [item release];
+    }
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Set as Focus" action:@selector(handleSetAsFocus:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    item.enabled = (collector_index != 0 || get_collector_index() != 0);
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    SEL action = get_view().get_show_level() ? @selector(handleOpenStripChartLevel:) : @selector(handleOpenStripChart:);
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Strip Chart" action:action keyEquivalent:@""];
+    item.target = _menu_delegate;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  if (!get_view().get_show_level()) {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Flame Graph" action:@selector(handleOpenFlameGraph:) keyEquivalent:@""];
+    item.target = _menu_delegate;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  [menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Change Color\u2026" action:@selector(handleChangeColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Reset Color" action:@selector(handleResetColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  return menu;
+}
+
+/**
+ * Called when the mouse hovers over a label, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsStripChart::
+get_label_tooltip(int collector_index) const {
+  return PStatStripChart::get_label_tooltip(collector_index);
+}
+
+/**
+ * Changes the value the height of the vertical axis represents.  This may
+ * force a redraw.
+ */
+void MacStatsStripChart::
+set_vertical_scale(double value_height) {
+  PStatStripChart::set_vertical_scale(value_height);
+
+  _graph_view.needsDisplay = YES;
+  _scale_area.needsDisplay = YES;
+}
+
+/**
+ * Sets the vertical scale to make all the data visible.
+ */
+void MacStatsStripChart::
+set_auto_vertical_scale() {
+  PStatStripChart::set_auto_vertical_scale();
+  set_vertical_scale(get_vertical_scale() * 1.5);
+
+  _graph_view.needsDisplay = YES;
+  _scale_area.needsDisplay = YES;
+}
+
+/**
+ * Resets the list of labels.
+ */
+void MacStatsStripChart::
+update_labels() {
+  PStatStripChart::update_labels();
+
+  _label_stack.clear_labels();
+  for (int i = 0; i < get_num_labels(); i++) {
+    _label_stack.add_label(MacStatsGraph::_monitor, this, _thread_index,
+                           get_label_collector(i), false);
+  }
+  _labels_changed = false;
+}
+
+/**
+ * Erases the chart area.
+ */
+void MacStatsStripChart::
+clear_region() {
+  if (_ctx) {
+    //CGContextSetFillColorWithColor(_ctx, _background_color);
+    //CGContextFillRect(_ctx, CGRectMake(0, 0, get_xsize(), get_ysize()));
+  }
+}
+
+/**
+ * Draws a single vertical slice of the strip chart, at the given pixel
+ * position, and corresponding to the indicated level data.
+ */
+void MacStatsStripChart::
+draw_slice(int x, int w, const PStatStripChart::FrameData &fdata) {
+  if (!_ctx) {
+    return;
+  }
+
+  // Start by clearing the band first.
+  CGContextSetFillColorWithColor(_ctx, _background_color);
+  CGContextFillRect(_ctx, CGRectMake(x, 0, w, get_ysize()));
+
+  double overall_time = 0.0;
+  int y = get_ysize();
+
+  FrameData::const_iterator fi;
+  for (fi = fdata.begin(); fi != fdata.end(); ++fi) {
+    const ColorData &cd = (*fi);
+    overall_time += cd._net_value;
+
+    bool is_highlighted = cd._collector_index == _highlighted_index;
+    CGContextSetFillColorWithColor(_ctx,
+      MacStatsGraph::_monitor->get_collector_color(cd._collector_index, is_highlighted));
+
+    if (overall_time > get_vertical_scale()) {
+      // Off the top.  Go ahead and clamp it by hand, in case it's so far off
+      // the top we'd overflow the 16-bit pixel value.
+      CGContextFillRect(_ctx, CGRectMake(x, 0, w, y));
+      // And we can consider ourselves done now.
+      return;
+    }
+
+    int top_y = height_to_pixel(overall_time);
+    CGContextFillRect(_ctx, CGRectMake(x, top_y, w, y - top_y));
+    y = top_y;
+  }
+}
+
+/**
+ * Draws a single vertical slice of background color.
+ */
+void MacStatsStripChart::
+draw_empty(int x, int w) {
+  if (!_ctx) {
+    return;
+  }
+
+  CGContextSetFillColorWithColor(_ctx, _background_color);
+  CGContextFillRect(_ctx, CGRectMake(x, 0, w, get_ysize()));
+}
+
+/**
+ * Draws a single vertical slice of foreground color.
+ */
+void MacStatsStripChart::
+draw_cursor(int x) {
+  if (!_ctx) {
+    return;
+  }
+
+  CGContextBeginPath(_ctx);
+  CGContextMoveToPoint(_ctx, x, 0);
+  CGContextAddLineToPoint(_ctx, x, get_ysize());
+  CGContextStrokePath(_ctx);
+}
+
+/**
+ * Should be overridden by the user class.  This hook will be called after
+ * drawing a series of color bars in the strip chart; it gives the pixel range
+ * that was just redrawn.
+ */
+void MacStatsStripChart::
+end_draw(int from_x, int to_x) {
+  _graph_view.needsDisplay = YES;
+}
+
+/**
+ * Returns the current window dimensions.
+ */
+bool MacStatsStripChart::
+get_window_state(int &x, int &y, int &width, int &height,
+                 bool &maximized, bool &minimized) const {
+  MacStatsGraph::get_window_state(x, y, width, height, maximized, minimized);
+  return true;
+}
+
+/**
+ * Called to restore the graph window to its previous dimensions.
+ */
+void MacStatsStripChart::
+set_window_state(int x, int y, int width, int height,
+                 bool maximized, bool minimized) {
+  MacStatsGraph::set_window_state(x, y, width, height, maximized, minimized);
+}
+
+/**
+ * Called when the mouse right-clicks on the graph, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsStripChart::
+get_graph_menu(int mouse_x, int mouse_y) const {
+  NSMenu *menu = nullptr;
+  if (_highlighted_index != -1) {
+    menu = get_label_menu(_highlighted_index);
+  }
+/*
+  if (menu != nullptr) {
+    [menu addItem:[NSMenuItem separatorItem]];
+  } else {
+    menu = [[[NSMenu alloc] init] autorelease];
+  }
+
+  NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Smooth" action:@selector(handleToggleStripChartAverage:) keyEquivalent:@""];
+  item.target = _graph_view_controller;
+  item.state = get_average_mode() ? NSOnState : NSOffState;
+  [menu addItem:item];
+  [item release];*/
+  return menu;
+}
+
+/**
+ * Called when the mouse hovers over the graph, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsStripChart::
+get_graph_tooltip(int mouse_x, int mouse_y) const {
+  if (_highlighted_index != -1) {
+    return get_label_tooltip(_highlighted_index);
+  }
+  return std::string();
+}
+
+/**
+ * Based on the mouse position within the graph window, look for draggable
+ * things the mouse might be hovering over and return the appropriate DragMode
+ * enum or DM_none if nothing is indicated.
+ */
+MacStatsGraph::DragMode MacStatsStripChart::
+consider_drag_start(int graph_x, int graph_y) {
+  // See if the mouse is over a user-defined guide bar.
+  int y = graph_y;
+  double from_height = pixel_to_height(y + 2);
+  double to_height = pixel_to_height(y - 2);
+  _drag_guide_bar = find_user_guide_bar(from_height, to_height);
+  if (_drag_guide_bar >= 0) {
+    return DM_guide_bar;
+  }
+
+  return MacStatsGraph::consider_drag_start(graph_x, graph_y);
+}
+
+/**
+ * This should be called whenever the drag mode needs to change state.  It
+ * provides hooks for a derived class to do something special.
+ */
+void MacStatsStripChart::
+set_drag_mode(MacStatsGraph::DragMode drag_mode) {
+  MacStatsGraph::set_drag_mode(drag_mode);
+}
+
+/**
+ * Called when the mouse button is depressed within the graph window.
+ */
+void MacStatsStripChart::
+handle_button_press(int graph_x, int graph_y, bool double_click, int button) {
+  if (graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    if (double_click && button == 0) {
+      // Double-clicking on a color bar in the graph is the same as double-
+      // clicking on the corresponding label.
+      on_click_label(get_collector_under_pixel(graph_x, graph_y));
+      return;
+    }
+
+    if (_potential_drag_mode == DM_none) {
+      set_drag_mode(DM_scale);
+      _drag_scale_start = pixel_to_height(graph_y);
+      // SetCapture(_graph_window);
+      return;
+    }
+  }
+
+  if (_potential_drag_mode == DM_guide_bar && _drag_guide_bar >= 0) {
+    set_drag_mode(DM_guide_bar);
+    _drag_start_y = graph_y;
+    // SetCapture(_graph_window);
+    return;
+  }
+
+  return MacStatsGraph::handle_button_press(graph_x, graph_y,
+                                            double_click, button);
+}
+
+/**
+ * Called when the mouse button is released within the graph window.
+ */
+void MacStatsStripChart::
+handle_button_release(int graph_x, int graph_y) {
+  if (_drag_mode == DM_scale) {
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+  else if (_drag_mode == DM_guide_bar) {
+    if (graph_y < 0 || graph_y >= get_ysize()) {
+      remove_user_guide_bar(_drag_guide_bar);
+    } else {
+      move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_y));
+    }
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+
+  return MacStatsGraph::handle_button_release(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsStripChart::
+handle_motion(int graph_x, int graph_y) {
+  if (_drag_mode == DM_none && _potential_drag_mode == DM_none &&
+      graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    // When the mouse is over a color bar, highlight it.
+    int collector_index = get_collector_under_pixel(graph_x, graph_y);
+    _label_stack.highlight_label(collector_index);
+    on_enter_label(collector_index);
+  }
+  else {
+    // If the mouse is in some drag mode, stop highlighting.
+    _label_stack.highlight_label(-1);
+    on_leave_label(_highlighted_index);
+  }
+
+  if (_drag_mode == DM_scale) {
+    double ratio = 1.0 - ((double)graph_y / (double)get_ysize());
+    if (ratio > 0.0) {
+      double new_scale = _drag_scale_start / ratio;
+      if (!IS_NEARLY_EQUAL(get_vertical_scale(), new_scale)) {
+        // Disable smoothing while we do this expensive operation.
+        set_vertical_scale(_drag_scale_start / ratio);
+      }
+    }
+    return;
+  }
+  else if (_drag_mode == DM_new_guide_bar) {
+    // We haven't created the new guide bar yet; we won't until the mouse
+    // comes within the graph's region.
+    if (graph_y >= 0 && graph_y < get_ysize()) {
+      set_drag_mode(DM_guide_bar);
+      _drag_guide_bar = add_user_guide_bar(pixel_to_height(graph_y));
+      return;
+    }
+  }
+  else if (_drag_mode == DM_guide_bar) {
+    move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_y));
+    return;
+  }
+
+  MacStatsGraph::handle_motion(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse has left the graph window.
+ */
+void MacStatsStripChart::
+handle_leave() {
+  _label_stack.highlight_label(-1);
+  on_leave_label(_highlighted_index);
+  return;
+}
+
+/**
+ *
+ */
+void MacStatsStripChart::
+handle_magnify(int graph_x, int graph_y, double scale) {
+  set_vertical_scale(get_vertical_scale() * (1.0 - scale));
+}
+
+/**
+ * Fills in the graph window.
+ */
+void MacStatsStripChart::
+handle_draw_graph(CGContextRef ctx, NSRect rect) {
+  MacStatsGraph::handle_draw_graph(ctx, rect);
+
+  int width = get_xsize();
+
+  // Draw in the guide bars.
+  int num_guide_bars = get_num_guide_bars();
+  for (int i = 0; i < num_guide_bars; i++) {
+    draw_guide_bar(ctx, 0, width, get_guide_bar(i));
+  }
+
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (int i = 0; i < num_user_guide_bars; i++) {
+    draw_guide_bar(ctx, 0, width, get_user_guide_bar(i));
+  }
+}
+
+/**
+ * Fills in the scale area.
+ */
+void MacStatsStripChart::
+handle_draw_scale_area(CGContextRef ctx, NSRect rect) {
+  MacStatsGraph::handle_draw_scale_area(ctx, rect);
+  draw_guide_labels(ctx);
+}
+
+/**
+ * Called when the mouse clicks the back button in the toolbar.
+ */
+void MacStatsStripChart::
+handle_back() {
+  if (!_back_stack.empty()) {
+    int collector_index = _back_stack.back();
+    _back_stack.pop_back();
+    set_collector_index(collector_index);
+
+    // Update window title and total label.
+    new_data(0, 0);
+    _scale_area.needsDisplay = YES;
+  }
+
+  if (_back_stack.empty()) {
+    _graph_view_controller.backToolbarItemVisible = NO;
+  }
+}
+
+/**
+ * Draws the line for the indicated guide bar on the graph.
+ */
+void MacStatsStripChart::
+draw_guide_bar(CGContextRef ctx, int from_x, int to_x,
+               const PStatGraph::GuideBar &bar) {
+  int y = height_to_pixel(bar._height);
+
+  if (y > 1) {
+    // Only draw it if it's not too close to the top.
+    /*switch (bar._style) {
+    case GBS_target:
+      CGContextSetRGBStrokeColor(ctx, rgb_light_gray[0], rgb_light_gray[1], rgb_light_gray[2], 1.0);
+      break;
+
+    case GBS_user:
+      CGContextSetRGBStrokeColor(ctx, rgb_user_guide_bar[0], rgb_user_guide_bar[1], rgb_user_guide_bar[2], 1.0);
+      break;
+
+    default:
+      CGContextSetRGBStrokeColor(ctx, rgb_dark_gray[0], rgb_dark_gray[1], rgb_dark_gray[2], 1.0);
+      break;
+    }*/
+    CGContextSetStrokeColorWithColor(ctx, [NSColor gridColor].CGColor);
+    CGContextBeginPath(ctx);
+    CGContextMoveToPoint(ctx, from_x, y);
+    CGContextAddLineToPoint(ctx, to_x, y);
+    CGContextStrokePath(ctx);
+  }
+}
+
+/**
+ * This is called during the servicing of the draw event.
+ */
+void MacStatsStripChart::
+draw_guide_labels(CGContextRef ctx) {
+  // Draw in the labels for the guide bars.
+  int last_y = 0;
+
+  int i;
+  int num_guide_bars = get_num_guide_bars();
+  for (i = 0; i < num_guide_bars; i++) {
+    last_y = draw_guide_label(ctx, get_guide_bar(i), last_y);
+  }
+
+  GuideBar top_value = make_guide_bar(get_vertical_scale());
+  draw_guide_label(ctx, top_value, last_y);
+
+  last_y = 0;
+  int num_user_guide_bars = get_num_user_guide_bars();
+  for (i = 0; i < num_user_guide_bars; i++) {
+    last_y = draw_guide_label(ctx, get_user_guide_bar(i), last_y);
+  }
+}
+
+/**
+ * Draws the text for the indicated guide bar label to the right of the graph,
+ * unless it would overlap with the indicated last label, whose top pixel
+ * value is given.  Returns the top pixel value of the new label.
+ */
+int MacStatsStripChart::
+draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar, int last_y) {
+  NSColor *color;
+  switch (bar._style) {
+  case GBS_target:
+    color = [NSColor secondaryLabelColor];
+    break;
+
+  case GBS_user:
+    color = [NSColor tertiaryLabelColor];
+    break;
+
+  default:
+    color = [NSColor labelColor];
+    break;
+  }
+
+  int y = height_to_pixel(bar._height);
+  const std::string &label = bar._label;
+
+  const CFStringRef keys[] = {
+    (__bridge CFStringRef)NSForegroundColorAttributeName,
+    (__bridge CFStringRef)NSFontAttributeName,
+  };
+  const void *values[] = {
+    color,
+    [NSFont systemFontOfSize:0.0],
+  };
+  CFDictionaryRef attribs = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+
+  CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, label.c_str(), kCFStringEncodingUTF8);
+  CFAttributedStringRef astr = CFAttributedStringCreate(kCFAllocatorDefault, str, attribs);
+  CFRelease(attribs);
+  CFRelease(str);
+
+  CTLineRef line = CTLineCreateWithAttributedString(astr);
+  CFRelease(astr);
+  CGRect bounds = CTLineGetImageBounds(line, ctx);
+  int height = bounds.size.height;
+
+  if (bar._style != GBS_user) {
+    double from_height = pixel_to_height(y + height);
+    double to_height = pixel_to_height(y - height);
+    if (find_user_guide_bar(from_height, to_height) >= 0) {
+      // Omit the label: there's a user-defined guide bar in the same space.
+      CFRelease(line);
+      return last_y;
+    }
+  }
+
+  if (y > height && y < get_ysize() - height) {
+    // Now convert our y to a coordinate within our drawing area.
+
+    int this_y = y - height / 2;
+    if (last_y < this_y || last_y > this_y + height) {
+      CGContextSetTextPosition(ctx, 4, get_ysize() - this_y - height);
+      CTLineDraw(line, ctx);
+
+      last_y = this_y;
+    }
+  }
+
+  CFRelease(line);
+  return last_y;
+}
+
+@implementation MacStatsStripChartViewController
+
+- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar {
+  if (@available(macOS 10.15, *)) {
+    return @[@"smooth", @"sep", @"total"];
+  } else {
+    return @[@"smooth", @"sep"];
+  }
+}
+
+- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar {
+  if (@available(macOS 10.15, *)) {
+    return @[@"smooth", @"sep", @"total"];
+  } else {
+    return @[@"smooth", @"sep"];
+  }
+}
+
+- (NSToolbarItem *) toolbar:(NSToolbar *)toolbar
+      itemForItemIdentifier:(NSString *)ident
+  willBeInsertedIntoToolbar:(BOOL)flag {
+
+  if (@available(macOS 11.0, *)) {
+    if ([ident isEqual:@"smooth"]) {
+      NSButton *button = [NSButton buttonWithTitle:@"Smooth" target:self action:@selector(handleToggleSmooth:)];
+      button.image = [NSImage imageWithSystemSymbolName:@"alternatingcurrent" accessibilityDescription:@""];
+      button.bezelStyle = NSBezelStyleTexturedRounded;
+      button.buttonType = NSButtonTypePushOnPushOff;
+      button.bordered = YES;
+      button.toolTip = @"Smooth";
+      button.state = NSOffState;
+      NSToolbarItem *item = [[[NSToolbarItem alloc] initWithItemIdentifier:ident] autorelease];
+      item.label = @"Smooth";
+      item.view = button;
+      return item;
+    }
+    if ([ident isEqual:@"total"]) {
+      NSToolbarItem *item = [[[NSToolbarItem alloc] initWithItemIdentifier:ident] autorelease];
+      item.label = @"Total";
+      item.action = @selector(handleClickTotal:);
+      item.target = self;
+      [item setBordered:YES];
+      return item;
+    }
+  }
+
+  return [super toolbar:toolbar itemForItemIdentifier:ident willBeInsertedIntoToolbar:flag];
+}
+
+- (void)handleToggleSmooth:(NSMenuItem *)item {
+  MacStatsStripChart *graph = (MacStatsStripChart *)_graph;
+  graph->set_average_mode(item.state == NSOnState);
+}
+
+- (void)handleClickTotal:(NSButton *)button {
+  MacStatsStripChart *graph = (MacStatsStripChart *)_graph;
+  graph->set_auto_vertical_scale();
+}
+
+@end

+ 100 - 0
pandatool/src/mac-stats/macStatsTimeline.h

@@ -0,0 +1,100 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsTimeline.h
+ * @author rdb
+ * @date 2023-08-19
+ */
+
+#ifndef MACSTATSTIMELINE_H
+#define MACSTATSTIMELINE_H
+
+#include "macStatsGraph.h"
+#include "pStatTimeline.h"
+
+class MacStatsMonitor;
+
+/**
+ * A window that draws all of the start/stop event pairs on each thread on a
+ * horizontal scrolling timeline, with concurrent start/stop pairs stacked
+ * underneath each other.
+ */
+class MacStatsTimeline final : public PStatTimeline, public MacStatsGraph {
+public:
+  MacStatsTimeline(MacStatsMonitor *monitor);
+  virtual ~MacStatsTimeline();
+
+  virtual void new_data(int thread_index, int frame_number);
+  virtual void force_redraw();
+  virtual void changed_graph_size(int graph_xsize, int graph_ysize);
+
+protected:
+  virtual void clear_region();
+  virtual void begin_draw();
+  virtual void draw_separator(int row);
+  virtual void draw_guide_bar(int x, GuideBarStyle style);
+  virtual void draw_bar(int row, int from_x, int to_x, int collector_index,
+                        const std::string &collector_name);
+  virtual void end_draw();
+  virtual void idle();
+
+  virtual bool animate(double time, double dt);
+
+  virtual bool get_window_state(int &x, int &y, int &width, int &height,
+                                bool &maximized, bool &minimized) const;
+  virtual void set_window_state(int x, int y, int width, int height,
+                                bool maximized, bool minimized);
+
+  virtual NSMenu *get_graph_menu(int mouse_x, int mouse_y) const;
+  virtual std::string get_graph_tooltip(int mouse_x, int mouse_y) const;
+  virtual DragMode consider_drag_start(int graph_x, int graph_y);
+
+  virtual bool handle_key(int graph_x, int graph_y, bool pressed,
+                          UniChar c, unsigned short key_code);
+  virtual void handle_button_press(int graph_x, int graph_y,
+                                       bool double_click, int button);
+  virtual void handle_button_release(int graph_x, int graph_y);
+  virtual void handle_motion(int graph_x, int graph_y);
+  virtual void handle_leave();
+  virtual void handle_scroll();
+  virtual void handle_wheel(int graph_x, int graph_y, double dx, double dy);
+  virtual void handle_magnify(int graph_x, int graph_y, double scale);
+  virtual void handle_draw_graph(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_graph_overhang(CGContextRef ctx, NSRect rect);
+  virtual void handle_draw_scale_area(CGContextRef ctx, NSRect rect);
+
+public:
+  void handle_zoom_to();
+  void handle_open_strip_chart();
+  void handle_open_flame_graph();
+  void handle_open_piano_roll();
+
+private:
+  void draw_guide_bar(CGContextRef ctx, GuideBarStyle style, int x, int y, int height);
+  void draw_guide_labels(CGContextRef ctx);
+  void draw_guide_label(CGContextRef ctx, const GuideBar &bar);
+
+  int row_to_pixel(int row) const {
+    return row * 4 * 5 + 4 - _scroll;
+  }
+  int pixel_to_row(int y) const {
+    return (y + _scroll - 4) / (4 * 5);
+  }
+
+  NSView *_thread_area;
+  pvector<std::pair<NSTextField *, NSLayoutConstraint *> > _thread_labels;
+  NSLayoutConstraint *_graph_height_constraint;
+  NSScrollView *_sidebar_scroll_view;
+
+  int _highlighted_row = -1;
+  int _highlighted_x = 0;
+  int _scroll = 0;
+  mutable ColorBar _popup_bar;
+};
+
+#endif

+ 932 - 0
pandatool/src/mac-stats/macStatsTimeline.mm

@@ -0,0 +1,932 @@
+/**
+ * PANDA 3D SOFTWARE
+ * Copyright (c) Carnegie Mellon University.  All rights reserved.
+ *
+ * All use of this software is subject to the terms of the revised BSD
+ * license.  You should have received a copy of this license along
+ * with this source code in a file named "LICENSE."
+ *
+ * @file macStatsTimeline.mm
+ * @author rdb
+ * @date 2023-08-19
+ */
+
+#include "macStatsTimeline.h"
+#include "macStatsMonitor.h"
+#include "macStatsLabelStack.h"
+#include "macStatsScaleArea.h"
+#include "pStatCollectorDef.h"
+
+@interface MacStatsTimelineViewController : MacStatsScrollableGraphViewController
+@end
+
+static const int default_timeline_width = 1000;
+static const int default_timeline_height = 300;
+
+static const int minimum_timeline_sidebar_width = 68;
+static const int default_timeline_sidebar_width = 100;
+
+/**
+ *
+ */
+MacStatsTimeline::
+MacStatsTimeline(MacStatsMonitor *monitor) :
+  PStatTimeline(monitor, 0, 0),
+  MacStatsGraph(monitor, [MacStatsTimelineViewController alloc])
+{
+  // Let's show the units on the guide bar labels.  There's room.
+  set_guide_bar_units(get_guide_bar_units() | GBU_show_units);
+
+  _window.title = @"Timeline";
+
+  if (@available(macOS 11.0, *)) {
+    _window.titleVisibility = NSWindowTitleHidden;
+  }
+
+  // Set the initial size of the graph.
+  const ThreadRow &last_thread_row = _threads.back();
+  int height = row_to_pixel(last_thread_row._row_offset + last_thread_row._rows.size());
+  if (height < default_timeline_height) {
+    height = default_timeline_height;
+  }
+  height += _window.frame.size.height - _window.contentLayoutRect.size.height;
+  _graph_view.frame = NSMakeRect(0, 0, default_timeline_width, height);
+  _graph_view_controller.view.frame = NSMakeRect(0, 0, default_timeline_width, height);
+
+  // Add a drawing area to the left of the graph to show the thread labels.
+  _thread_area = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, default_timeline_sidebar_width, 0)];
+  _thread_area.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
+  _thread_area.translatesAutoresizingMaskIntoConstraints = NO;
+
+  // It's put inside a scroll view that tracks the main scroll view.
+  NSScrollView *scroll_view = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 0, default_timeline_sidebar_width, 0)];
+  scroll_view.documentView = _thread_area;
+  scroll_view.drawsBackground = NO;
+  scroll_view.automaticallyAdjustsContentInsets = YES;
+  //scroll_view.translatesAutoresizingMaskIntoConstraints = NO;
+  scroll_view.hasHorizontalScroller = NO;
+  scroll_view.hasVerticalScroller = NO;
+  _sidebar_scroll_view = scroll_view;
+
+  NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
+  [center addObserver:_graph_view_controller
+             selector:@selector(handleSideScroll:)
+                 name:NSScrollViewDidLiveScrollNotification
+               object:scroll_view];
+
+  NSViewController *sidebar_controller = [[NSViewController alloc] init];
+  sidebar_controller.view = scroll_view;
+
+  NSSplitViewController *svc = [[NSSplitViewController alloc] init];
+  [svc addSplitViewItem:[NSSplitViewItem sidebarWithViewController:sidebar_controller]];
+  [svc addSplitViewItem:[NSSplitViewItem splitViewItemWithViewController:_graph_view_controller]];
+
+  svc.splitViewItems[0].minimumThickness = minimum_timeline_sidebar_width;
+  svc.splitViewItems[0].canCollapse = NO;
+
+  NSSplitView *split_view = svc.splitView;
+  split_view.vertical = YES;
+  split_view.dividerStyle = NSSplitViewDividerStyleThin;
+  split_view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
+  _split_view = split_view;
+
+  _window.contentViewController = svc;
+
+  [svc release];
+  [sidebar_controller release];
+
+  // Scale area goes on top, as a titlebar accessory view.
+  MacStatsScaleAreaController *scale_area_controller = [[MacStatsScaleAreaController alloc] initWithGraph:this];
+  scale_area_controller.fullScreenMinHeight = 20;
+  scale_area_controller.layoutAttribute = NSLayoutAttributeRight;
+  _scale_area = scale_area_controller.view;
+  [_window addTitlebarAccessoryViewController:scale_area_controller];
+  [scale_area_controller release];
+
+  [_thread_area.widthAnchor constraintEqualToAnchor:scroll_view.widthAnchor].active = YES;
+  [_thread_area.heightAnchor constraintEqualToAnchor:_graph_view.heightAnchor].active = YES;
+
+  _graph_height_constraint = [_graph_view.heightAnchor constraintGreaterThanOrEqualToConstant:0];
+  _graph_height_constraint.active = YES;
+
+  [_window makeKeyAndOrderFront:nil];
+}
+
+/**
+ *
+ */
+MacStatsTimeline::
+~MacStatsTimeline() {
+  [_thread_area release];
+  [_sidebar_scroll_view release];
+}
+
+/**
+ * Called as each frame's data is made available.  There is no guarantee the
+ * frames will arrive in order, or that all of them will arrive at all.  The
+ * monitor should be prepared to accept frames received out-of-order or
+ * missing.
+ */
+void MacStatsTimeline::
+new_data(int thread_index, int frame_number) {
+  PStatTimeline::new_data(thread_index, frame_number);
+}
+
+/**
+ * Called when it is necessary to redraw the entire graph.
+ */
+void MacStatsTimeline::
+force_redraw() {
+  if (_ctx) {
+    PStatTimeline::force_redraw();
+  }
+}
+
+/**
+ * Called when the user has resized the window, forcing a resize of the graph.
+ */
+void MacStatsTimeline::
+changed_graph_size(int graph_xsize, int graph_ysize) {
+  PStatTimeline::changed_size(graph_xsize, graph_ysize);
+}
+
+/**
+ * Erases the chart area.
+ */
+void MacStatsTimeline::
+clear_region() {
+  if (_ctx) {
+    CGContextSetFillColorWithColor(_ctx, _background_color);
+    CGContextFillRect(_ctx, CGRectMake(0, 0, get_xsize(), get_ysize()));
+
+    CGContextSetTextMatrix(_ctx, CGAffineTransformMakeScale(1, -1));
+    //draw_guide_labels(_ctx);
+  }
+}
+
+/**
+ * Erases the chart area in preparation for drawing a bunch of bars.
+ */
+void MacStatsTimeline::
+begin_draw() {
+}
+
+/**
+ * Draws a horizontal separator.
+ */
+void MacStatsTimeline::
+draw_separator(int row) {
+  if (_ctx) {
+    CGContextSetFillColorWithColor(_ctx, [NSColor gridColor].CGColor);
+    CGContextFillRect(_ctx, CGRectMake(0, (row_to_pixel(row) + row_to_pixel(row + 1)) / 2.0, get_xsize(), 4.0 / 3.0));
+  }
+}
+
+/**
+ * Draws a vertical guide bar.  If the row is -1, draws it in all rows.
+ */
+void MacStatsTimeline::
+draw_guide_bar(int x, GuideBarStyle style) {
+  draw_guide_bar(_ctx, style, x, 0, get_ysize());
+}
+
+/**
+ * Draws a single bar in the chart for the indicated row, in the color for the
+ * given collector, for the indicated horizontal pixel range.
+ */
+void MacStatsTimeline::
+draw_bar(int row, int from_x, int to_x, int collector_index,
+         const std::string &collector_name) {
+  int top = row_to_pixel(row);
+  int bottom = row_to_pixel(row + 1);
+  int scale = 4;
+
+  top += 1;
+
+  MacStatsMonitor *monitor = MacStatsGraph::_monitor;
+
+  bool is_highlighted = row == _highlighted_row && _highlighted_x >= from_x && _highlighted_x < to_x;
+  CGContextSetFillColorWithColor(_ctx,
+    monitor->get_collector_color(collector_index, is_highlighted));
+
+  if (to_x < from_x + 1) {
+    // Too tiny to draw.
+  }
+  else if (to_x < from_x + scale) {
+    // It's just a tiny sliver.  This is a more reliable way to draw it.
+    CGRect rect = CGRectMake(from_x, top, to_x - from_x, bottom - top);
+    CGContextFillRect(_ctx, rect);
+  }
+  else {
+    int left = std::max(from_x, -scale - 1);
+    int right = std::min(std::max(to_x, from_x + 1), get_xsize() + scale);
+
+    double radius = std::min((double)scale, (right - left) / 2.0);
+    CGContextBeginPath(_ctx);
+    CGContextAddArc(_ctx, right - radius - 0.5, top + radius, radius, -0.5 * M_PI, 0.0, NO);
+    CGContextAddArc(_ctx, right - radius - 0.5, bottom - radius, radius, 0.0, 0.5 * M_PI, NO);
+    CGContextAddArc(_ctx, left + radius, bottom - radius, radius, 0.5 * M_PI, M_PI, NO);
+    CGContextAddArc(_ctx, left + radius, top + radius, radius, M_PI, 1.5 * M_PI, NO);
+    CGContextClosePath(_ctx);
+    CGContextFillPath(_ctx);
+
+    if ((to_x - from_x) >= scale * 4) {
+      // Only bother drawing the text if we've got some space to draw on.
+      const PStatClientData *client_data = monitor->get_client_data();
+      const PStatCollectorDef &def = client_data->get_collector_def(collector_index);
+
+      const CFStringRef keys[] = {
+        (__bridge CFStringRef)NSForegroundColorAttributeName,
+        (__bridge CFStringRef)NSFontAttributeName,
+      };
+      const void *values[] = {
+        monitor->get_collector_text_color(collector_index, is_highlighted),
+        [NSFont systemFontOfSize:0.0],
+      };
+      CFDictionaryRef attribs = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+
+      CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, def._name.c_str(), kCFStringEncodingUTF8);
+      CFAttributedStringRef astr = CFAttributedStringCreate(kCFAllocatorDefault, str, attribs);
+
+      CTLineRef line = CTLineCreateWithAttributedString(astr);
+      CGRect bounds = CTLineGetImageBounds(line, _ctx);
+      CFRelease(astr);
+      CFRelease(str);
+
+      int text_width = bounds.size.width;
+      int text_height = bounds.size.height;
+
+      double center = (from_x + to_x) / 2.0;
+      double text_left = std::max(from_x, 0) + scale / 2.0;
+      double text_right = std::min(to_x, get_xsize()) - scale / 2.0;
+      double text_top = top + (bottom - top - text_height) / 2.0 + text_height;
+/*
+      if (text_width >= text_right - text_left) {
+        size_t c = collector_name.rfind(':');
+        if (text_right - text_left < scale * 6) {
+          // It's a really tiny space.  Draw a single letter.
+          const char *ch = collector_name.data() + (c != std::string::npos ? c + 1 : 0);
+          pango_layout_set_alignment(layout, PANGO_ALIGN_CENTER);
+          pango_layout_set_text(layout, ch, 1);
+        } else {
+          // Maybe just use everything after the last colon.
+          if (c != std::string::npos) {
+            pango_layout_set_text(layout, collector_name.data() + c + 1,
+                                          collector_name.size() - c - 1);
+            pango_layout_get_pixel_size(layout, &text_width, &text_height);
+          }
+        }
+      }*/
+
+      if (text_width >= text_right - text_left) {
+        // Have CoreText truncate to the correct length.
+        static CFStringRef token_str = CFSTR("\u2026");
+        CFAttributedStringRef token_astr = CFAttributedStringCreate(kCFAllocatorDefault, token_str, attribs);
+        CTLineRef token_line = CTLineCreateWithAttributedString(token_astr);
+        CTLineRef trunc_line = CTLineCreateTruncatedLine(line, text_right - text_left, kCTLineTruncationEnd, token_line);
+        CFRelease(line);
+        CFRelease(token_astr);
+        CFRelease(token_line);
+        line = trunc_line;
+        CGContextSetTextPosition(_ctx, text_left, text_top);
+      }
+      else if (center - text_width / 2.0 < 0.0) {
+        // Put it against the left-most edge.
+        CGContextSetTextPosition(_ctx, scale, text_top);
+      }
+      else if (center + text_width / 2.0 >= get_xsize()) {
+        // Put it against the right-most edge.
+        CGContextSetTextPosition(_ctx, get_xsize() - scale - text_width, text_top);
+      }
+      else {
+        // It fits just fine, center it.
+        CGContextSetTextPosition(_ctx, center - text_width / 2.0, text_top);
+      }
+
+      if (line != nullptr) {
+        CTLineDraw(line, _ctx);
+        CFRelease(line);
+      }
+      CFRelease(attribs);
+    }
+  }
+}
+
+/**
+ * Called after all the bars have been drawn, this triggers a refresh event to
+ * draw it to the window.
+ */
+void MacStatsTimeline::
+end_draw() {
+  // Recalculate the size of the graph.
+  if (!_threads.empty()) {
+    const ThreadRow &last_thread_row = _threads.back();
+    int new_height = row_to_pixel(last_thread_row._row_offset + last_thread_row._rows.size());
+    _graph_height_constraint.constant = new_height;
+  }
+
+  _graph_view.needsDisplay = YES;
+
+  // If we scroll sideways while we're also scrolling vertically such that the
+  // overhang becomes visible due to elasticity, the overhang doesn't update.
+  // I could only find this private method for fixing this problem.
+  // Not needed as of macOS 14 and up, since the normal drawRect DTRT there.
+#if __MAC_OS_X_VERSION_MAX_ALLOWED < 140000
+  NSClipView *clip_view = ((MacStatsScrollableGraphViewController *)_graph_view_controller).clipView;
+  if ([clip_view respondsToSelector:@selector(_setNeedsDisplayInOverhang:)]) {
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wobjc-method-access"
+    [clip_view _setNeedsDisplayInOverhang:YES];
+#pragma clang diagnostic pop
+  }
+#endif
+
+  if (_threads_changed) {
+    while (_thread_labels.size() < _threads.size()) {
+      NSTextField *label = [NSTextField labelWithString:@"Thread"];
+      label.translatesAutoresizingMaskIntoConstraints = NO;
+      [_thread_area addSubview:label];
+
+      [label.rightAnchor constraintEqualToAnchor:_thread_area.rightAnchor constant:-8].active = YES;
+      [_thread_area.widthAnchor constraintGreaterThanOrEqualToAnchor:label.widthAnchor constant:16].active = YES;
+
+      NSLayoutConstraint *constraint;
+      if (@available(macOS 11.0, *)) {
+        constraint = [label.topAnchor constraintEqualToAnchor:_thread_area.topAnchor];
+      } else {
+        constraint = [label.topAnchor constraintEqualToAnchor:_thread_area.topAnchor];
+      }
+      constraint.active = YES;
+
+      _thread_labels.push_back(std::make_pair(label, constraint));
+    }
+
+    for (size_t i = 0; i < _threads.size(); ++i) {
+      const ThreadRow &thread_row = _threads[i];
+      NSTextField *label = _thread_labels[i].first;
+      NSLayoutConstraint *label_constraint = _thread_labels[i].second;
+
+      label.stringValue = [NSString stringWithUTF8String:thread_row._label.c_str()];
+      label_constraint.constant = row_to_pixel(thread_row._row_offset);
+    }
+
+    _thread_area.needsDisplay = YES;
+    _threads_changed = false;
+  }
+
+  if (_guide_bars_changed) {
+    _scale_area.needsDisplay = YES;
+    _guide_bars_changed = false;
+  }
+}
+
+/**
+ * Called at the end of the draw cycle.
+ */
+void MacStatsTimeline::
+idle() {
+}
+
+/**
+ * Overridden by a derived class to implement an animation.  If it returns
+ * false, the animation timer is stopped.
+ */
+bool MacStatsTimeline::
+animate(double time, double dt) {
+  return PStatTimeline::animate(time, dt);
+}
+
+/**
+ * Returns the current window dimensions.
+ */
+bool MacStatsTimeline::
+get_window_state(int &x, int &y, int &width, int &height,
+                 bool &maximized, bool &minimized) const {
+  MacStatsGraph::get_window_state(x, y, width, height, maximized, minimized);
+  return true;
+}
+
+/**
+ * Called to restore the graph window to its previous dimensions.
+ */
+void MacStatsTimeline::
+set_window_state(int x, int y, int width, int height,
+                 bool maximized, bool minimized) {
+  MacStatsGraph::set_window_state(x, y, width, height, maximized, minimized);
+}
+
+/**
+ * Called when the mouse right-clicks on the graph, and should return the menu
+ * that should pop up.
+ */
+NSMenu *MacStatsTimeline::
+get_graph_menu(int graph_x, int graph_y) const {
+  int row = pixel_to_row(graph_y);
+  ColorBar bar;
+  if (!find_bar(row, graph_x, bar)) {
+    return nil;
+  }
+
+  _popup_bar = bar;
+
+  NSMenu *menu = [[[NSMenu alloc] init] autorelease];
+
+  std::string label = get_bar_tooltip(row, graph_x);
+  if (!label.empty()) {
+    if (@available(macOS 14.0, *)) {
+      [menu addItem:[NSMenuItem sectionHeaderWithTitle:[NSString stringWithUTF8String:label.c_str()]]];
+    } else {
+      NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:[NSString stringWithUTF8String:label.c_str()] action:nil keyEquivalent:@""];
+      item.enabled = NO;
+      [menu addItem:item];
+      [item release];
+    }
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Zoom To" action:@selector(handleZoomTo:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Strip Chart" action:@selector(handleOpenStripChart:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Flame Graph" action:@selector(handleOpenFlameGraph:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Open Piano Roll" action:@selector(handleOpenPianoRoll:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    [menu addItem:item];
+    [item release];
+  }
+
+  [menu addItem:[NSMenuItem separatorItem]];
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Change Color\u2026" action:@selector(handleChangeColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = bar._collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  {
+    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:@"Reset Color" action:@selector(handleResetColor:) keyEquivalent:@""];
+    item.target = _graph_view_controller;
+    item.tag = bar._collector_index;
+    [menu addItem:item];
+    [item release];
+  }
+
+  return menu;
+}
+
+/**
+ * Called when the mouse hovers over the graph, and should return the text that
+ * should appear on the tooltip.
+ */
+std::string MacStatsTimeline::
+get_graph_tooltip(int mouse_x, int mouse_y) const {
+  return PStatTimeline::get_bar_tooltip(pixel_to_row(mouse_y), mouse_x);
+}
+
+/**
+ * Based on the mouse position within the graph window, look for draggable
+ * things the mouse might be hovering over and return the appropriate DragMode
+ * enum or DM_none if nothing is indicated.
+ */
+MacStatsGraph::DragMode MacStatsTimeline::
+consider_drag_start(int graph_x, int graph_y) {
+  return MacStatsGraph::consider_drag_start(graph_x, graph_y);
+}
+
+/**
+ *
+ */
+bool MacStatsTimeline::
+handle_key(int graph_x, int graph_y, bool pressed, UniChar c, unsigned short key_code) {
+  // Accept WASD based on their position rather than their mapping
+  int flag = 0;
+  switch (key_code) {
+  case 13:
+    flag = F_w;
+    break;
+  case 0:
+    flag = F_a;
+    break;
+  case 1:
+    flag = F_s;
+    break;
+  case 2:
+    flag = F_d;
+    break;
+  }
+  if (flag == 0) {
+    switch (c) {
+    case 0x1c:
+    case NSLeftArrowFunctionKey:
+      flag = F_left;
+      break;
+    case 0x1d:
+    case NSRightArrowFunctionKey:
+      flag = F_right;
+      break;
+    case 'w':
+      flag = F_w;
+      break;
+    case 'a':
+      flag = F_a;
+      break;
+    case 's':
+      flag = F_s;
+      break;
+    case 'd':
+      flag = F_d;
+      break;
+    }
+  }
+  if (flag != 0) {
+    if (pressed) {
+      if (flag & (F_w | F_s)) {
+        _zoom_center = pixel_to_timestamp(graph_x);
+      }
+      if (_keys_held == 0) {
+        start_animation();
+      }
+      _keys_held |= flag;
+    }
+    else if (_keys_held != 0) {
+      _keys_held &= ~flag;
+    }
+    return true;
+  }
+  return false;
+}
+
+/**
+ * Called when the mouse button is depressed within the graph window.
+ */
+void MacStatsTimeline::
+handle_button_press(int graph_x, int graph_y, bool double_click, int button) {
+  if (graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    if (double_click && button == 0) {
+      // Double-clicking on a color bar in the graph will zoom the graph into
+      // that collector.
+      int row = pixel_to_row(graph_y);
+      ColorBar bar;
+      if (find_bar(row, graph_x, bar)) {
+        double width = bar._end - bar._start;
+        zoom_to(width * 1.5, pixel_to_timestamp(graph_x));
+        scroll_to(bar._start - width / 4.0);
+      } else {
+        // Double-clicking the white area zooms out.
+        _zoom_speed -= 100.0;
+      }
+      start_animation();
+    }
+
+    if (_potential_drag_mode == DM_none) {
+      set_drag_mode(DM_pan);
+      _drag_start_x = graph_x;
+      _scroll_speed = 0.0;
+      _zoom_center = pixel_to_timestamp(graph_x);
+      return;
+    }
+  }
+
+  return MacStatsGraph::handle_button_press(graph_x, graph_y,
+                                            double_click, button);
+}
+
+/**
+ * Called when the mouse button is released within the graph window.
+ */
+void MacStatsTimeline::
+handle_button_release(int graph_x, int graph_y) {
+  if (_drag_mode == DM_scale) {
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+  else if (_drag_mode == DM_guide_bar) {
+    if (graph_x < 0 || graph_x >= get_xsize()) {
+      remove_user_guide_bar(_drag_guide_bar);
+    } else {
+      move_user_guide_bar(_drag_guide_bar, pixel_to_height(graph_x));
+    }
+    set_drag_mode(DM_none);
+    // ReleaseCapture();
+    return handle_motion(graph_x, graph_y);
+  }
+
+  return MacStatsGraph::handle_button_release(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsTimeline::
+handle_motion(int graph_x, int graph_y) {
+  if (_drag_mode == DM_none && _potential_drag_mode == DM_none &&
+      graph_x >= 0 && graph_y >= 0 && graph_x < get_xsize() && graph_y < get_ysize()) {
+    // When the mouse is over a color bar, highlight it.
+    int row = pixel_to_row(graph_y);
+    std::swap(_highlighted_x, graph_x);
+    std::swap(_highlighted_row, row);
+
+    if (row >= 0) {
+      PStatTimeline::force_redraw(row, graph_x, graph_x);
+    }
+    PStatTimeline::force_redraw(_highlighted_row, _highlighted_x, _highlighted_x);
+
+    if ((_keys_held & (F_w | F_s)) != 0) {
+      // Update the zoom center if we move the mouse while zooming with the
+      // keyboard.
+      _zoom_center = pixel_to_timestamp(graph_x);
+    }
+  }
+  else {
+    // If the mouse is in some drag mode, stop highlighting.
+    if (_highlighted_row != -1) {
+      int row = _highlighted_row;
+      _highlighted_row = -1;
+      PStatTimeline::force_redraw(row, _highlighted_x, _highlighted_x);
+    }
+  }
+
+  if (_drag_mode == DM_pan) {
+    int delta = _drag_start_x - graph_x;
+    _drag_start_x = graph_x;
+    set_horizontal_scroll(get_horizontal_scroll() + pixel_to_height(delta));
+    return;
+  }
+
+  return MacStatsGraph::handle_motion(graph_x, graph_y);
+}
+
+/**
+ * Called when the mouse has left the graph window.
+ */
+void MacStatsTimeline::
+handle_leave() {
+  if (_highlighted_row != -1) {
+    int row = _highlighted_row;
+    _highlighted_row = -1;
+    PStatTimeline::force_redraw(row, _highlighted_x, _highlighted_x);
+  }
+}
+
+/**
+ * Called when the mouse is moved within the graph window.
+ */
+void MacStatsTimeline::
+handle_scroll() {
+  // Graph view is flipped, side bar isn't, so we need to convert coordinates
+  NSPoint point;
+  point.x = 0;
+  point.y = _graph_view.frame.size.height - (((NSScrollView *)_graph_view_controller.view).documentVisibleRect.size.height + ((NSScrollView *)_graph_view_controller.view).documentVisibleRect.origin.y);
+  [_sidebar_scroll_view.contentView scrollToPoint:point];
+  [_sidebar_scroll_view reflectScrolledClipView:_sidebar_scroll_view.contentView];
+}
+
+/**
+ *
+ */
+void MacStatsTimeline::
+handle_wheel(int graph_x, int graph_y, double dx, double dy) {
+  if (dx != 0.0) {
+    _scroll_speed -= dx;
+    start_animation();
+  }
+}
+
+/**
+ *
+ */
+void MacStatsTimeline::
+handle_magnify(int graph_x, int graph_y, double scale) {
+  zoom_by(scale * 4.0, pixel_to_timestamp(graph_x));
+  start_animation();
+}
+
+/**
+ * Fills in the graph window.
+ */
+void MacStatsTimeline::
+handle_draw_graph(CGContextRef ctx, NSRect rect) {
+#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 140000
+  for (const GuideBar &bar : _guide_bars) {
+    int x = timestamp_to_pixel(bar._height);
+    draw_guide_bar(ctx, bar._style, x, rect.origin.y, rect.size.height);
+  }
+#endif
+
+  MacStatsGraph::handle_draw_graph(ctx, rect);
+
+  NSRect scale_frame = _scale_area.frame;
+  NSRect graph_frame = _graph_view.frame;
+  if (scale_frame.size.width != graph_frame.size.width) {
+    scale_frame.size.width = graph_frame.size.width;
+    _scale_area.frame = scale_frame;
+  }
+}
+
+/**
+ * Fills in the graph window overhang, which is the area outside the graph
+ * bounds that may become visible momentarily due to scroll elasticity.
+ */
+void MacStatsTimeline::
+handle_draw_graph_overhang(CGContextRef ctx, NSRect rect) {
+  CGContextSetFillColorWithColor(ctx, _background_color);
+  CGContextFillRect(ctx, rect);
+
+  for (const GuideBar &bar : _guide_bars) {
+    int x = timestamp_to_pixel(bar._height);
+    draw_guide_bar(ctx, bar._style, x, rect.origin.y, rect.size.height);
+  }
+}
+
+/**
+ * Fills in the scale area.
+ */
+void MacStatsTimeline::
+handle_draw_scale_area(CGContextRef ctx, NSRect rect) {
+  MacStatsGraph::handle_draw_scale_area(ctx, rect);
+
+  draw_guide_labels(ctx);
+
+  CGContextSetFillColorWithColor(ctx, [NSColor gridColor].CGColor);
+
+  for (const GuideBar &bar : _guide_bars) {
+    int x = timestamp_to_pixel(bar._height);
+    x = [_scale_area convertPoint:NSMakePoint(x, 0) fromView:_graph_view].x;
+    draw_guide_bar(ctx, bar._style, x, rect.origin.y, rect.size.height);
+  }
+}
+
+/**
+ *
+ */
+void MacStatsTimeline::
+handle_zoom_to() {
+  const ColorBar &bar = _popup_bar;
+  double width = bar._end - bar._start;
+  zoom_to(width * 1.5, (bar._end + bar._start) / 2.0);
+  scroll_to(bar._start - width / 4.0);
+  start_animation();
+}
+
+/**
+ *
+ */
+void MacStatsTimeline::
+handle_open_strip_chart() {
+  const ColorBar &bar = _popup_bar;
+  MacStatsGraph::_monitor->open_strip_chart(bar._thread_index, bar._collector_index, false);
+}
+
+/**
+ *
+ */
+void MacStatsTimeline::
+handle_open_flame_graph() {
+  const ColorBar &bar = _popup_bar;
+  MacStatsGraph::_monitor->open_flame_graph(bar._thread_index, bar._collector_index);
+}
+
+/**
+ *
+ */
+void MacStatsTimeline::
+handle_open_piano_roll() {
+  const ColorBar &bar = _popup_bar;
+  MacStatsGraph::_monitor->open_piano_roll(bar._thread_index);
+}
+
+/**
+ * Draws a vertical guide bar.  If the row is -1, draws it in all rows.
+ */
+void MacStatsTimeline::
+draw_guide_bar(CGContextRef ctx, GuideBarStyle style, int x, int y, int height) {
+  double width = 1.0;
+  if (style == GBS_frame) {
+    width *= 2;
+  }
+
+  CGContextSetFillColorWithColor(ctx, [NSColor gridColor].CGColor);
+  CGContextFillRect(ctx, CGRectMake(x - width / 2.0, y, width, height));
+}
+
+/**
+ * This is called during the servicing of the draw event.
+ */
+void MacStatsTimeline::
+draw_guide_labels(CGContextRef ctx) {
+  int num_guide_bars = get_num_guide_bars();
+  for (int i = 0; i < num_guide_bars; i++) {
+    draw_guide_label(ctx, get_guide_bar(i));
+  }
+}
+
+/**
+ * Draws the text for the indicated guide bar label at the top of the graph.
+ */
+void MacStatsTimeline::
+draw_guide_label(CGContextRef ctx, const PStatGraph::GuideBar &bar) {
+  const std::string &label = bar._label;
+  if (label.empty()) {
+    return;
+  }
+
+  NSColor *color;
+  if (@available(macOS 11.0, *)) {
+    color = [NSColor tertiaryLabelColor];
+  } else {
+    // Otherwise it's hard to see on the dark titlebars
+    color = [NSColor windowFrameTextColor];
+  }
+  /*switch (bar._style) {
+  case GBS_target:
+    color = [NSColor colorWithDeviceRed:rgb_light_gray[0] green:rgb_light_gray[1] blue:rgb_light_gray[2] alpha:1.0];
+    break;
+
+  case GBS_user:
+    color = [NSColor colorWithDeviceRed:rgb_user_guide_bar[0] green:rgb_user_guide_bar[1] blue:rgb_user_guide_bar[2] alpha:1.0];
+    break;
+
+  case GBS_normal:
+    color = [NSColor colorWithDeviceRed:rgb_light_gray[0] green:rgb_light_gray[1] blue:rgb_light_gray[2] alpha:1.0];
+    break;
+
+  case GBS_frame:
+    color = [NSColor colorWithDeviceRed:rgb_dark_gray[0] green:rgb_dark_gray[1] blue:rgb_dark_gray[2] alpha:1.0];
+    break;
+  }*/
+
+  const CFStringRef keys[] = {
+    (__bridge CFStringRef)NSForegroundColorAttributeName,
+    (__bridge CFStringRef)NSFontAttributeName,
+  };
+  const void *values[] = {
+    color,
+    [NSFont systemFontOfSize:0.0],
+  };
+  CFDictionaryRef attribs = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, (const void **)values, 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+
+  CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, label.c_str(), kCFStringEncodingUTF8);
+  CFAttributedStringRef astr = CFAttributedStringCreate(kCFAllocatorDefault, str, attribs);
+  CFRelease(attribs);
+  CFRelease(str);
+
+  CTLineRef line = CTLineCreateWithAttributedString(astr);
+  CFRelease(astr);
+  CGRect bounds = CTLineGetImageBounds(line, ctx);
+  //int height = bounds.size.height;
+  int width = bounds.size.width;
+
+  NSRect graph_bounds = _graph_view.bounds;
+
+  int x = timestamp_to_pixel(bar._height);
+  x = [_scale_area convertPoint:NSMakePoint(x, 0) fromView:_graph_view].x;
+
+  if (x + width >= 0 && x + width < get_xsize()) {
+    if (x + width < graph_bounds.size.width) {
+      CGContextSetTextPosition(ctx, x + 6, 6);
+      CTLineDraw(line, ctx);
+    }
+  }
+
+  CFRelease(line);
+}
+
+@implementation MacStatsTimelineViewController
+
+- (void)handleZoomTo:(NSMenuItem *)item {
+  ((MacStatsTimeline *)_graph)->handle_zoom_to();
+}
+
+- (void)handleOpenStripChart:(NSMenuItem *)item {
+  ((MacStatsTimeline *)_graph)->handle_open_strip_chart();
+}
+
+- (void)handleOpenFlameGraph:(NSMenuItem *)item {
+  ((MacStatsTimeline *)_graph)->handle_open_flame_graph();
+}
+
+- (void)handleOpenPianoRoll:(NSMenuItem *)item {
+  ((MacStatsTimeline *)_graph)->handle_open_piano_roll();
+}
+
+@end
+

+ 16 - 0
pandatool/src/mac-stats/macstats_composite1.mm

@@ -0,0 +1,16 @@
+#include "macStats.mm"
+#include "macStatsAppDelegate.mm"
+#include "macStatsChartMenu.mm"
+#include "macStatsChartMenuDelegate.mm"
+#include "macStatsFlameGraph.mm"
+#include "macStatsGraph.mm"
+#include "macStatsGraphView.mm"
+#include "macStatsGraphViewController.mm"
+#include "macStatsLabel.mm"
+#include "macStatsLabelStack.mm"
+#include "macStatsMonitor.mm"
+#include "macStatsPianoRoll.mm"
+#include "macStatsScaleArea.mm"
+#include "macStatsServer.mm"
+#include "macStatsStripChart.mm"
+#include "macStatsTimeline.mm"

+ 5 - 0
pandatool/src/pstatserver/pStatStripChart.cxx

@@ -988,7 +988,12 @@ draw_frames(int first_frame, int last_frame) {
     if (_scroll_mode) {
       // In scrolling mode, slide the world back.
       int slide_pixels = last_pixel - _xsize;
+      // This is really slow on macOS, just redraw instead
+#ifdef __APPLE__
+      draw_pixels(0, first_pixel - slide_pixels);
+#else
       copy_region(slide_pixels, first_pixel, 0);
+#endif
       first_pixel -= slide_pixels;
       last_pixel -= slide_pixels;
       _start_time += (double)slide_pixels / (double)_xsize * _time_width;