diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/FlatClientProperties.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/FlatClientProperties.java index 9962d69fc..37798c3c9 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/FlatClientProperties.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/FlatClientProperties.java @@ -296,6 +296,8 @@ public interface FlatClientProperties * and should therefore return quickly. *
  • This function is invoked on 'AWT-Windows' thread (not 'AWT-EventQueue' thread) * while processing Windows messages. + * On macOS in fullWindowContent mode (since 3.7.2), it is invoked on the AppKit + * main thread before the mouse event is dispatched to AWT. * It must not change any component property or layout because this could cause a dead lock. * *

    diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java index e3f1034cb..d53175a2b 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java @@ -45,7 +45,7 @@ */ public class FlatNativeMacLibrary { - private static int API_VERSION_MACOS = 2004; + private static int API_VERSION_MACOS = 2005; /** * Checks whether native library is loaded/available. @@ -71,6 +71,41 @@ public static boolean isLoaded() { /** @since 3.4 */ public native static boolean isWindowFullScreen( Window window ); /** @since 3.4 */ public native static boolean toggleWindowFullScreen( Window window ); + /** + * Registers a hit-test callback used to decide whether a left mouse down event + * in a fullWindowContent macOS window's title bar area should start a native + * window drag instead of being delivered to AWT. + *

    + * The window must already use {@code apple.awt.fullWindowContent=true}. + * Passing the same window twice replaces the previously registered callback. + * + * @since 3.7.2 + */ + public native static boolean setupFullWindowContentTitleBarCaption( Window window, FullWindowContentTitleBarCaptionCallback callback ); + + /** + * Removes the callback previously installed with + * {@link #setupFullWindowContentTitleBarCaption(Window, FullWindowContentTitleBarCaptionCallback)}. + * + * @since 3.7.2 + */ + public native static boolean removeFullWindowContentTitleBarCaption( Window window ); + + /** @since 3.7.2 */ + public interface FullWindowContentTitleBarCaptionCallback { + /** + * Invoked on the AppKit main thread (not the AWT event dispatching thread) + * before the mouse event is dispatched to AWT. + * It must not change any component property or layout because this could cause a dead lock. + * + * @param x x coordinate in AWT window coordinates (origin top-left) + * @param y y coordinate in AWT window coordinates (origin top-left) + * @return {@code true} if the location should behave as title bar caption + * (i.e. start a native window drag instead of being delivered to AWT) + */ + boolean isTitleBarCaptionAt( int x, int y ); + } + /** @since 3.7 */ public static final int diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatRootPaneUI.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatRootPaneUI.java index 2db0382fa..a4344a773 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatRootPaneUI.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatRootPaneUI.java @@ -199,8 +199,11 @@ protected void uninstallDefaults( JRootPane c ) { protected void installListeners( JRootPane root ) { super.installListeners( root ); - if( SystemInfo.isMacFullWindowContentSupported ) + if( SystemInfo.isMacFullWindowContentSupported ) { macFullWindowContentListener = FullWindowContentSupport.macInstallListeners( root ); + if( FlatClientProperties.clientPropertyBoolean( root, "apple.awt.fullWindowContent", false ) ) + FullWindowContentSupport.macInstallTitleBarCaption( root ); + } macInstallWindowBackgroundListener( root ); } @@ -210,6 +213,7 @@ protected void uninstallListeners( JRootPane root ) { if( SystemInfo.isMacFullWindowContentSupported ) { FullWindowContentSupport.macUninstallListeners( root, macFullWindowContentListener ); + FullWindowContentSupport.macUninstallTitleBarCaption( root ); macFullWindowContentListener = null; } macUninstallWindowBackgroundListener( root ); @@ -536,8 +540,13 @@ public void propertyChange( PropertyChangeEvent e ) { // "apple.awt.fullWindowContent" or FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING // is usually done before the native window is created // --> try again when native window is created - if( e.getNewValue() instanceof Window ) + if( e.getNewValue() instanceof Window ) { macInstallFullWindowContentSupport(); + // title bar caption support also requires a native window + if( SystemInfo.isMacFullWindowContentSupported && + FlatClientProperties.clientPropertyBoolean( rootPane, "apple.awt.fullWindowContent", false ) ) + FullWindowContentSupport.macInstallTitleBarCaption( rootPane ); + } break; case FlatClientProperties.MACOS_WINDOW_BUTTONS_SPACING: @@ -545,8 +554,13 @@ public void propertyChange( PropertyChangeEvent e ) { break; case "apple.awt.fullWindowContent": - if( SystemInfo.isMacFullWindowContentSupported ) + if( SystemInfo.isMacFullWindowContentSupported ) { FullWindowContentSupport.macUpdateFullWindowContentButtonsBoundsProperty( rootPane ); + if( FlatClientProperties.clientPropertyBoolean( rootPane, "apple.awt.fullWindowContent", false ) ) + FullWindowContentSupport.macInstallTitleBarCaption( rootPane ); + else + FullWindowContentSupport.macUninstallTitleBarCaption( rootPane ); + } break; } } diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatTitlePane.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatTitlePane.java index 3c32be0ac..ad2a336ab 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatTitlePane.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatTitlePane.java @@ -48,7 +48,6 @@ import java.beans.PropertyChangeListener; import java.util.List; import java.util.Objects; -import java.util.function.Function; import javax.accessibility.AccessibleContext; import javax.swing.BorderFactory; import javax.swing.Box; @@ -65,7 +64,6 @@ import javax.swing.UIManager; import javax.swing.border.AbstractBorder; import javax.swing.border.Border; -import javax.swing.plaf.ComponentUI; import com.formdev.flatlaf.FlatClientProperties; import com.formdev.flatlaf.FlatSystemProperties; import com.formdev.flatlaf.ui.FlatNativeWindowBorder.WindowTopBorder; @@ -1145,77 +1143,7 @@ private boolean captionHitTest( Point pt ) { } private boolean isTitleBarCaptionAt( Component c, int x, int y ) { - if( !c.isDisplayable() || !c.isVisible() || !contains( c, x, y ) || c == mouseLayer ) - return true; // continue checking with next component - - // check enabled component that has mouse listeners - if( c.isEnabled() && - (c.getMouseListeners().length > 0 || - c.getMouseMotionListeners().length > 0) ) - { - if( !(c instanceof JComponent) ) - return false; // assume that this is not a caption because the component has mouse listeners - - // check client property boolean value - Object caption = ((JComponent)c).getClientProperty( COMPONENT_TITLE_BAR_CAPTION ); - if( caption instanceof Boolean ) - return (boolean) caption; - - // if component is not fully layouted, do not invoke function - // because it is too dangerous that the function tries to layout the component, - // which could cause a dead lock - if( !c.isValid() ) { - // revalidate if necessary so that it is valid when invoked again later - EventQueue.invokeLater( () -> { - Window w = SwingUtilities.windowForComponent( c ); - if( w != null ) - w.revalidate(); - else - c.revalidate(); - } ); - - return false; // assume that this is not a caption because the component has mouse listeners - } - - if( caption instanceof Function ) { - // check client property function value - @SuppressWarnings( "unchecked" ) - Function hitTest = (Function) caption; - Boolean result = hitTest.apply( new Point( x, y ) ); - if( result != null ) - return result; - } else { - // check component UI - ComponentUI ui = JavaCompatibility2.getUI( (JComponent) c ); - if( !(ui instanceof TitleBarCaptionHitTest) ) - return false; // assume that this is not a caption because the component has mouse listeners - - Boolean result = ((TitleBarCaptionHitTest)ui).isTitleBarCaptionAt( x, y ); - if( result != null ) - return result; - } - - // else continue checking children - } - - // check children - if( c instanceof Container ) { - for( Component child : ((Container)c).getComponents() ) { - if( !isTitleBarCaptionAt( child, x - child.getX(), y - child.getY() ) ) - return false; - } - } - return true; - } - - /** - * Same as {@link Component#contains(int, int)}, but not using that method - * because it may be overridden by custom components and invoke code that - * tries to request AWT tree lock on 'AWT-Windows' thread. - * This could freeze the application if AWT tree is already locked on 'AWT-EventQueue' thread. - */ - private boolean contains( Component c, int x, int y ) { - return x >= 0 && y >= 0 && x < c.getWidth() && y < c.getHeight(); + return FullWindowContentSupport.isTitleBarCaptionAt( c, x, y, mouseLayer ); } private int lastCaptionHitTestX; diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FullWindowContentSupport.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FullWindowContentSupport.java index d5e080b95..bc961b859 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FullWindowContentSupport.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FullWindowContentSupport.java @@ -18,8 +18,11 @@ import java.awt.Color; import java.awt.Component; +import java.awt.Container; import java.awt.Dimension; +import java.awt.EventQueue; import java.awt.Graphics; +import java.awt.Point; import java.awt.Rectangle; import java.awt.Window; import java.awt.event.ComponentAdapter; @@ -28,11 +31,15 @@ import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; +import java.util.function.Function; import javax.swing.JComponent; +import javax.swing.JLayeredPane; import javax.swing.JRootPane; import javax.swing.SwingUtilities; import javax.swing.UIManager; +import javax.swing.plaf.ComponentUI; import com.formdev.flatlaf.FlatClientProperties; +import com.formdev.flatlaf.ui.FlatTitlePane.TitleBarCaptionHitTest; import com.formdev.flatlaf.util.SystemInfo; /** @@ -216,4 +223,168 @@ private static void debugPaintRect( Graphics g, Rectangle r ) { g.drawLine( r.x, y2, x2, r.y ); FlatUIUtils.resetRenderingHints( g, oldRenderingHints ); } + + /** + * Returns whether there is a component at the given location that processes + * mouse events; checks {@link FlatClientProperties#COMPONENT_TITLE_BAR_CAPTION} + * and {@link TitleBarCaptionHitTest} along the way. + *

    + * Used by {@link FlatTitlePane} (Windows native window decorations). + * + * @param skip optional component that is skipped during traversal + * (used to skip {@code FlatTitlePane.mouseLayer}) + */ + static boolean isTitleBarCaptionAt( Component c, int x, int y, Component skip ) { + if( !c.isDisplayable() || !c.isVisible() || !contains( c, x, y ) || c == skip ) + return true; // continue checking with next component + + // check enabled component that has mouse listeners + if( c.isEnabled() && + (c.getMouseListeners().length > 0 || + c.getMouseMotionListeners().length > 0) ) + { + if( !(c instanceof JComponent) ) + return false; // assume that this is not a caption because the component has mouse listeners + + // check client property boolean value + Object caption = ((JComponent)c).getClientProperty( FlatClientProperties.COMPONENT_TITLE_BAR_CAPTION ); + if( caption instanceof Boolean ) + return (boolean) caption; + + // if component is not fully layouted, do not invoke function + // because it is too dangerous that the function tries to layout the component, + // which could cause a dead lock + if( !c.isValid() ) { + // revalidate if necessary so that it is valid when invoked again later + EventQueue.invokeLater( () -> { + Window w = SwingUtilities.windowForComponent( c ); + if( w != null ) + w.revalidate(); + else + c.revalidate(); + } ); + + return false; // assume that this is not a caption because the component has mouse listeners + } + + if( caption instanceof Function ) { + // check client property function value + @SuppressWarnings( "unchecked" ) + Function hitTest = (Function) caption; + Boolean result = hitTest.apply( new Point( x, y ) ); + if( result != null ) + return result; + } else { + // check component UI + ComponentUI ui = JavaCompatibility2.getUI( (JComponent) c ); + if( !(ui instanceof TitleBarCaptionHitTest) ) + return false; // assume that this is not a caption because the component has mouse listeners + + Boolean result = ((TitleBarCaptionHitTest)ui).isTitleBarCaptionAt( x, y ); + if( result != null ) + return result; + } + + // else continue checking children + } + + // check children + if( c instanceof Container ) { + for( Component child : ((Container)c).getComponents() ) { + if( !isTitleBarCaptionAt( child, x - child.getX(), y - child.getY(), skip ) ) + return false; + } + } + return true; + } + + /** + * Same as {@link Component#contains(int, int)}, but not using that method + * because it may be overridden by custom components and invoke code that + * tries to request AWT tree lock on 'AWT-Windows' thread. + * This could freeze the application if AWT tree is already locked on 'AWT-EventQueue' thread. + */ + private static boolean contains( Component c, int x, int y ) { + return x >= 0 && y >= 0 && x < c.getWidth() && y < c.getHeight(); + } + + //---- macOS title bar caption support ------------------------------------ + // + // On macOS in fullWindowContent mode, the title bar area is transparent and + // Swing content extends into it. To make components marked with + // JComponent.titleBarCaption act as window-draggable caption (even when they + // have mouse listeners), we register a per-window callback with the native + // library. The native side (MacTitleBarCaption.mm) intercepts left mouse + // down events in the title bar area before AWT dispatch and, on caption + // points, hands off to -[NSWindow performWindowDragWithEvent:]. + + static void macInstallTitleBarCaption( JRootPane rootPane ) { + if( !SystemInfo.isMacFullWindowContentSupported || !FlatNativeMacLibrary.isLoaded() ) + return; + + Window window = SwingUtilities.getWindowAncestor( rootPane ); + if( window == null || !window.isDisplayable() ) + return; + + FlatNativeMacLibrary.setupFullWindowContentTitleBarCaption( window, + new MacTitleBarCaptionCallback( rootPane ) ); + } + + static void macUninstallTitleBarCaption( JRootPane rootPane ) { + if( !SystemInfo.isMacFullWindowContentSupported || !FlatNativeMacLibrary.isLoaded() ) + return; + + Window window = SwingUtilities.getWindowAncestor( rootPane ); + if( window == null || !window.isDisplayable() ) + return; + + FlatNativeMacLibrary.removeFullWindowContentTitleBarCaption( window ); + } + + private static class MacTitleBarCaptionCallback + implements FlatNativeMacLibrary.FullWindowContentTitleBarCaptionCallback + { + private final WeakReference rootPaneRef; + + MacTitleBarCaptionCallback( JRootPane rootPane ) { + this.rootPaneRef = new WeakReference<>( rootPane ); + } + + /** + * Invoked on the AppKit main thread (not the AWT event dispatching + * thread), before the mouse event is dispatched to AWT. + * Must return quickly and must not change any component property or + * layout because this could cause a dead lock. + */ + @Override + public boolean isTitleBarCaptionAt( int x, int y ) { + JRootPane rootPane = rootPaneRef.get(); + if( rootPane == null || !rootPane.isShowing() ) + return false; + + // bail out quickly if click is below the title bar + Rectangle buttonsBounds = (Rectangle) rootPane.getClientProperty( + FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_BOUNDS ); + int titleBarHeight = (buttonsBounds != null && buttonsBounds.height > 0) + ? buttonsBounds.height + : 28; // default size, matches macUpdateFullWindowContentButtonsBoundsProperty() + + // convert from AWT window coordinates to layeredPane coordinates + // by walking the parent chain; not using SwingUtilities.convertPoint() + // because that calls Component.getLocationOnScreen(), which acquires + // the AWT tree lock and could cause a dead lock here + JLayeredPane layeredPane = rootPane.getLayeredPane(); + int dx = 0, dy = 0; + for( Component c = layeredPane; c != null && !(c instanceof Window); c = c.getParent() ) { + dx += c.getX(); + dy += c.getY(); + } + int lx = x - dx; + int ly = y - dy; + if( ly < 0 || ly >= titleBarHeight ) + return false; + + return FullWindowContentSupport.isTitleBarCaptionAt( layeredPane, lx, ly, null ); + } + } } diff --git a/flatlaf-natives/flatlaf-natives-macos/src/main/headers/com_formdev_flatlaf_ui_FlatNativeMacLibrary.h b/flatlaf-natives/flatlaf-natives-macos/src/main/headers/com_formdev_flatlaf_ui_FlatNativeMacLibrary.h index 663f16598..e3e6e5277 100644 --- a/flatlaf-natives/flatlaf-natives-macos/src/main/headers/com_formdev_flatlaf_ui_FlatNativeMacLibrary.h +++ b/flatlaf-natives/flatlaf-natives-macos/src/main/headers/com_formdev_flatlaf_ui_FlatNativeMacLibrary.h @@ -87,6 +87,22 @@ JNIEXPORT jboolean JNICALL Java_com_formdev_flatlaf_ui_FlatNativeMacLibrary_isWi JNIEXPORT jboolean JNICALL Java_com_formdev_flatlaf_ui_FlatNativeMacLibrary_toggleWindowFullScreen (JNIEnv *, jclass, jobject); +/* + * Class: com_formdev_flatlaf_ui_FlatNativeMacLibrary + * Method: setupFullWindowContentTitleBarCaption + * Signature: (Ljava/awt/Window;Lcom/formdev/flatlaf/ui/FlatNativeMacLibrary/FullWindowContentTitleBarCaptionCallback;)Z + */ +JNIEXPORT jboolean JNICALL Java_com_formdev_flatlaf_ui_FlatNativeMacLibrary_setupFullWindowContentTitleBarCaption + (JNIEnv *, jclass, jobject, jobject); + +/* + * Class: com_formdev_flatlaf_ui_FlatNativeMacLibrary + * Method: removeFullWindowContentTitleBarCaption + * Signature: (Ljava/awt/Window;)Z + */ +JNIEXPORT jboolean JNICALL Java_com_formdev_flatlaf_ui_FlatNativeMacLibrary_removeFullWindowContentTitleBarCaption + (JNIEnv *, jclass, jobject); + /* * Class: com_formdev_flatlaf_ui_FlatNativeMacLibrary * Method: showFileChooser diff --git a/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/ApiVersion.mm b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/ApiVersion.mm index 5929ad67c..5f9041470 100644 --- a/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/ApiVersion.mm +++ b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/ApiVersion.mm @@ -24,7 +24,7 @@ // increase this version if changing API or functionality of native library // also update version in Java class com.formdev.flatlaf.ui.FlatNativeMacLibrary -#define API_VERSION_MACOS 2004 +#define API_VERSION_MACOS 2005 //---- JNI methods ------------------------------------------------------------ diff --git a/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacTitleBarCaption.mm b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacTitleBarCaption.mm new file mode 100644 index 000000000..19ead1243 --- /dev/null +++ b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacTitleBarCaption.mm @@ -0,0 +1,293 @@ +/* + * Copyright 2026 FormDev Software GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import +#import +#import "JNIUtils.h" +#import "JNFRunLoop.h" +#import "MacWindowInternal.h" +#import "com_formdev_flatlaf_ui_FlatNativeMacLibrary.h" + +/** + * Title bar drag delegation for fullWindowContent windows. + * + * A FlatTitleBarDragView is inserted as a sibling of the standard window + * buttons inside NSTitlebarView, ordered below them. AppKit's normal + * hit-testing therefore continues to route traffic-light and contentView + * clicks correctly; only clicks on the bare title-bar background land on us. + * + * On mouseDown we ask the registered Java callback whether the click is over + * a caption region. If yes and the user starts dragging, we hand off via + * -[NSWindow performWindowDragWithEvent:] so AppKit performs the drag with + * native edge-snap, magnetic alignment, etc. Otherwise the click is + * forwarded to the contentView and Swing sees it as normal. + * + * Mirrors the JetBrains Runtime AWTWindowDragView pattern. + * + * @since 3.7.2 + */ + + +static JavaVM* _jvm = NULL; +static jmethodID _isTitleBarCaptionAtMethodID = NULL; + +// Looks up the callback method ID once and caches it. Returns false if the +// interface or method cannot be resolved. +static bool ensureMethodIDs( JNIEnv* env ) { + if( _isTitleBarCaptionAtMethodID != NULL ) + return true; + jclass cls = findClass( env, + "com/formdev/flatlaf/ui/FlatNativeMacLibrary$FullWindowContentTitleBarCaptionCallback", + false ); + if( cls == NULL ) + return false; + _isTitleBarCaptionAtMethodID = getMethodID( env, cls, "isTitleBarCaptionAt", "(II)Z", false ); + return _isTitleBarCaptionAtMethodID != NULL; +} + +// Asks the registered Java callback whether the event location is over a +// caption region. Always invoked on the AppKit main thread. +static bool isCaptionEvent( NSWindow* nsWindow, NSEvent* event ) { + if( nsWindow == NULL || _jvm == NULL || _isTitleBarCaptionAtMethodID == NULL ) + return false; + WindowData* windowData = getWindowData( nsWindow, false ); + if( windowData == NULL || windowData.titleBarCaptionCallback == NULL ) + return false; + + // convert event location from NSWindow (bottom-up) to AWT window (top-down) coordinates + NSPoint loc = event.locationInWindow; + jint x = (jint) loc.x; + jint y = (jint) (nsWindow.frame.size.height - loc.y); + + jboolean result = JNI_FALSE; + JNI_THREAD_ENTER( _jvm, false ) + result = env->CallBooleanMethod( windowData.titleBarCaptionCallback, + _isTitleBarCaptionAtMethodID, x, y ); + if( env->ExceptionCheck() ) { + env->ExceptionDescribe(); + env->ExceptionClear(); + result = JNI_FALSE; + } + JNI_THREAD_EXIT( _jvm ) + return (bool) result; +} + + +//---- class FlatTitleBarDragView --------------------------------------------- + +@interface FlatTitleBarDragView : NSView { + BOOL _captionTracking; +} +@end + +@implementation FlatTitleBarDragView + + // don't let AppKit start an automatic background drag; we own drag dispatch + - (BOOL) mouseDownCanMoveWindow { return NO; } + + // allow first click on an inactive window to be a single activate-and-drag gesture + - (BOOL) acceptsFirstMouse:(NSEvent*)event { return YES; } + + // claim every click in our bounds; forwarding to the contentView happens + // in the mouse handlers below. Returning nil would let NSTitlebarView treat + // the click as a background drag. + - (NSView*) hitTest:(NSPoint)pointInSuper { + NSPoint local = [self.superview convertPoint:pointInSuper toView:self]; + return NSPointInRect( local, self.bounds ) ? self : nil; + } + + - (void) mouseDown:(NSEvent*)event { + NSWindow* w = self.window; + _captionTracking = isCaptionEvent( w, event ); + + // always forward so Swing sees the click. If the user starts dragging + // a caption point, -mouseDragged: hands off to AppKit; otherwise the + // full click/release sequence reaches Swing as normal. + [w.contentView mouseDown:event]; + } + + - (void) mouseDragged:(NSEvent*)event { + if( _captionTracking ) { + // performWindowDragWithEvent: is a synchronous modal loop; the + // next mouseDragged: cannot arrive until it returns, so a re-entry + // guard isn't needed + _captionTracking = NO; + NSWindow* w = self.window; + [w performWindowDragWithEvent:event]; + + // AppKit's modal loop ate the mouseUp, so the original mouseDown + // we forwarded never sees a release. Synthesize an off-screen + // mouseUp so Swing cancels the press without it counting as a + // click on the originally-pressed component. + NSEvent* synthMouseUp = [NSEvent mouseEventWithType:NSEventTypeLeftMouseUp + location:NSMakePoint( -1, -1 ) + modifierFlags:event.modifierFlags + timestamp:[NSDate timeIntervalSinceReferenceDate] + windowNumber:event.windowNumber + context:nil + eventNumber:event.eventNumber + clickCount:0 + pressure:0.0]; + [w.contentView mouseUp:synthMouseUp]; + return; + } + [self.window.contentView mouseDragged:event]; + } + + - (void) mouseUp:(NSEvent*)event { + [self.window.contentView mouseUp:event]; + + if( !_captionTracking || event.clickCount != 2 ) { + _captionTracking = NO; + return; + } + _captionTracking = NO; + + // run the user's "double click on title bar" preference + NSWindow* w = self.window; + NSString* action = [[NSUserDefaults standardUserDefaults] + stringForKey:@"AppleActionOnDoubleClick"]; + if( action == nil || [action caseInsensitiveCompare:@"Maximize"] == NSOrderedSame ) + [w zoom:nil]; + else if( [action caseInsensitiveCompare:@"Minimize"] == NSOrderedSame ) + [w miniaturize:nil]; + } + +@end + + +// Idempotent. Must be invoked on the main thread. +static void attachTitleBarDragViewIfNeeded( NSWindow* nsWindow ) { + WindowData* windowData = getWindowData( nsWindow, true ); + if( windowData.titleBarDragView != nil ) { + // AppKit may have detached the view from outside our control + // (e.g. across a full-screen overlay swap). Re-attach if so. + if( windowData.titleBarDragView.superview != nil ) + return; + windowData.titleBarDragView = nil; + } + + NSView* closeButton = [nsWindow standardWindowButton:NSWindowCloseButton]; + if( closeButton == nil ) + return; + NSView* titlebar = closeButton.superview; + if( titlebar == nil ) + return; + + FlatTitleBarDragView* dragView = [[FlatTitleBarDragView alloc] initWithFrame:titlebar.bounds]; + dragView.translatesAutoresizingMaskIntoConstraints = NO; + // insert below the buttons so they hit-test first + [titlebar addSubview:dragView positioned:NSWindowBelow relativeTo:closeButton]; + [NSLayoutConstraint activateConstraints:@[ + [dragView.leadingAnchor constraintEqualToAnchor:titlebar.leadingAnchor], + [dragView.trailingAnchor constraintEqualToAnchor:titlebar.trailingAnchor], + [dragView.topAnchor constraintEqualToAnchor:titlebar.topAnchor], + [dragView.bottomAnchor constraintEqualToAnchor:titlebar.bottomAnchor] + ]]; + windowData.titleBarDragView = dragView; +} + +// Must be invoked on the main thread. +static void detachTitleBarDragView( NSWindow* nsWindow ) { + WindowData* windowData = getWindowData( nsWindow, false ); + if( windowData == nil || windowData.titleBarDragView == nil ) + return; + [windowData.titleBarDragView removeFromSuperview]; + windowData.titleBarDragView = nil; +} + +// Releases a JNI global ref using the JNIEnv attached to the current thread. +// No-op if 'ref' or the cached JavaVM is NULL. +static void releaseGlobalRefOnMainThread( jobject ref ) { + if( ref == NULL || _jvm == NULL ) + return; + JNIEnv* mainEnv; + if( _jvm->GetEnv( (void**) &mainEnv, JNI_VERSION_1_6 ) == JNI_OK ) + mainEnv->DeleteGlobalRef( ref ); +} + + +extern "C" +JNIEXPORT jboolean JNICALL Java_com_formdev_flatlaf_ui_FlatNativeMacLibrary_setupFullWindowContentTitleBarCaption + ( JNIEnv* env, jclass cls, jobject window, jobject callback ) +{ + JNI_COCOA_ENTER() + + NSWindow* nsWindow = getNSWindow( env, cls, window ); + if( nsWindow == NULL || callback == NULL ) + return FALSE; + + // cache JavaVM and method ID once + if( _jvm == NULL ) + env->GetJavaVM( &_jvm ); + if( !ensureMethodIDs( env ) ) + return FALSE; + + jobject globalCallback = env->NewGlobalRef( callback ); + if( globalCallback == NULL ) + return FALSE; + + [FlatJNFRunLoop performOnMainThreadWaiting:YES withBlock:^(){ + JNI_COCOA_TRY() + + WindowData* windowData = getWindowData( nsWindow, true ); + + // release previous callback (if any) + jobject previous = windowData.titleBarCaptionCallback; + windowData.titleBarCaptionCallback = globalCallback; + releaseGlobalRefOnMainThread( previous ); + + attachTitleBarDragViewIfNeeded( nsWindow ); + + JNI_COCOA_CATCH() + }]; + + return TRUE; + + JNI_COCOA_EXIT() + return FALSE; +} + +extern "C" +JNIEXPORT jboolean JNICALL Java_com_formdev_flatlaf_ui_FlatNativeMacLibrary_removeFullWindowContentTitleBarCaption + ( JNIEnv* env, jclass cls, jobject window ) +{ + JNI_COCOA_ENTER() + + NSWindow* nsWindow = getNSWindow( env, cls, window ); + if( nsWindow == NULL ) + return FALSE; + + [FlatJNFRunLoop performOnMainThreadWaiting:YES withBlock:^(){ + JNI_COCOA_TRY() + + WindowData* windowData = getWindowData( nsWindow, false ); + if( windowData != NULL ) { + detachTitleBarDragView( nsWindow ); + + jobject previous = windowData.titleBarCaptionCallback; + windowData.titleBarCaptionCallback = NULL; + releaseGlobalRefOnMainThread( previous ); + } + + JNI_COCOA_CATCH() + }]; + + return TRUE; + + JNI_COCOA_EXIT() + return FALSE; +} diff --git a/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindow.mm b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindow.mm index 1958c60bb..5a428c3d6 100644 --- a/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindow.mm +++ b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindow.mm @@ -19,31 +19,17 @@ #import #import "JNIUtils.h" #import "JNFRunLoop.h" +#import "MacWindowInternal.h" #import "com_formdev_flatlaf_ui_FlatNativeMacLibrary.h" /** * @author Karl Tauber */ -@interface WindowData : NSObject - // used when window is full screen - @property (nonatomic) int lastWindowButtonAreaWidth; - @property (nonatomic) int lastWindowTitleBarHeight; - - // full screen observers - @property (nonatomic) id willEnterFullScreenObserver; - @property (nonatomic) id willExitFullScreenObserver; - @property (nonatomic) id didExitFullScreenObserver; -@end - @implementation WindowData @end -// declare exported methods -NSWindow* getNSWindow( JNIEnv* env, jclass cls, jobject window ); - // declare internal methods -static WindowData* getWindowData( NSWindow* nsWindow, bool allocate ); static void setWindowButtonsHidden( NSWindow* nsWindow, bool hidden ); static int getWindowButtonAreaWidth( NSWindow* nsWindow ); static int getWindowTitleBarHeight( NSWindow* nsWindow ); @@ -81,7 +67,7 @@ @implementation WindowData return (NSWindow *) jlong_to_ptr( env->GetLongField( platformWindow, ptrID ) ); } -static WindowData* getWindowData( NSWindow* nsWindow, bool allocate ) { +WindowData* getWindowData( NSWindow* nsWindow, bool allocate ) { static char key; WindowData* windowData = objc_getAssociatedObject( nsWindow, &key ); if( windowData == NULL && allocate ) { diff --git a/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindowInternal.h b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindowInternal.h new file mode 100644 index 000000000..1d0bada2b --- /dev/null +++ b/flatlaf-natives/flatlaf-natives-macos/src/main/objcpp/MacWindowInternal.h @@ -0,0 +1,41 @@ +/* + * Copyright 2026 FormDev Software GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import +#import + + +// per-NSWindow state shared between MacWindow.mm and MacTitleBarCaption.mm; +// associated with the NSWindow via objc_setAssociatedObject in getWindowData() +@interface WindowData : NSObject + // used when window is full screen + @property (nonatomic) int lastWindowButtonAreaWidth; + @property (nonatomic) int lastWindowTitleBarHeight; + + // full screen observers + @property (nonatomic) id willEnterFullScreenObserver; + @property (nonatomic) id willExitFullScreenObserver; + @property (nonatomic) id didExitFullScreenObserver; + + // title bar caption (MacTitleBarCaption.mm) + @property (nonatomic) jobject titleBarCaptionCallback; + @property (nonatomic) NSView* titleBarDragView; +@end + + +// defined in MacWindow.mm +NSWindow* getNSWindow( JNIEnv* env, jclass cls, jobject window ); +WindowData* getWindowData( NSWindow* nsWindow, bool allocate ); diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.java b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.java index d4887189c..9341a437d 100644 --- a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.java +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.java @@ -57,6 +57,11 @@ public static void main( String[] args ) { placeholderPanel.putClientProperty( FlatClientProperties.FULL_WINDOW_CONTENT_BUTTONS_PLACEHOLDER, "mac zeroInFullScreen" ); UIManager.put( "FlatLaf.debug.panel.showPlaceholders", true ); + + // the two buttons demonstrate caption=true vs caption=false side by + // side. Toggled via captionDemoCheckBox below. + captionTrueButton.putClientProperty( FlatClientProperties.COMPONENT_TITLE_BAR_CAPTION, true ); + captionFalseButton.putClientProperty( FlatClientProperties.COMPONENT_TITLE_BAR_CAPTION, false ); } @Override @@ -130,6 +135,23 @@ private void toggleFullScreen() { FlatNativeMacLibrary.toggleWindowFullScreen( window ); } + private void captionDemoChanged() { + captionDemoButtonsPanel.setVisible( captionDemoCheckBox.isSelected() ); + } + + private void captionTrueClicked() { + captionTrueClickCount++; + captionTrueButton.setText( "caption=true (clicks: " + captionTrueClickCount + ")" ); + } + + private void captionFalseClicked() { + captionFalseClickCount++; + captionFalseButton.setText( "caption=false (clicks: " + captionFalseClickCount + ")" ); + } + + private int captionTrueClickCount; + private int captionFalseClickCount; + private void initComponents() { // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents JPanel panel1 = new JPanel(); @@ -151,6 +173,10 @@ private void initComponents() { JLabel nativeButtonsBoundsLabel = new JLabel(); nativeButtonsBoundsField = new JLabel(); JButton toggleFullScreenButton = new JButton(); + captionDemoButtonsPanel = new JPanel(); + captionTrueButton = new JButton(); + captionFalseButton = new JButton(); + captionDemoCheckBox = new JCheckBox(); //======== this ======== setLayout(new BorderLayout()); @@ -165,6 +191,23 @@ private void initComponents() { placeholderPanel.setLayout(new FlowLayout()); } panel1.add(placeholderPanel, BorderLayout.WEST); + + //======== captionDemoButtonsPanel ======== + { + captionDemoButtonsPanel.setVisible(false); + captionDemoButtonsPanel.setLayout(new FlowLayout()); + + //---- captionTrueButton ---- + captionTrueButton.setText("caption=true (clicks: 0)"); + captionTrueButton.addActionListener(e -> captionTrueClicked()); + captionDemoButtonsPanel.add(captionTrueButton); + + //---- captionFalseButton ---- + captionFalseButton.setText("caption=false (clicks: 0)"); + captionFalseButton.addActionListener(e -> captionFalseClicked()); + captionDemoButtonsPanel.add(captionFalseButton); + } + panel1.add(captionDemoButtonsPanel, BorderLayout.EAST); } add(panel1, BorderLayout.PAGE_START); @@ -185,6 +228,7 @@ private void initComponents() { "[fill]" + "[]" + "[]para" + + "[]" + "[]")); //---- fullWindowContentCheckBox ---- @@ -262,6 +306,11 @@ private void initComponents() { toggleFullScreenButton.setText("Toggle Full Screen"); toggleFullScreenButton.addActionListener(e -> toggleFullScreen()); panel2.add(toggleFullScreenButton, "cell 0 6"); + + //---- captionDemoCheckBox ---- + captionDemoCheckBox.setText("Show caption demo buttons"); + captionDemoCheckBox.addActionListener(e -> captionDemoChanged()); + panel2.add(captionDemoCheckBox, "cell 0 7 4 1"); } add(panel2, BorderLayout.CENTER); @@ -287,5 +336,9 @@ private void initComponents() { private JLabel buttonsSpacingHint; private JLabel fullWindowContentButtonsBoundsField; private JLabel nativeButtonsBoundsField; + private JPanel captionDemoButtonsPanel; + private JButton captionTrueButton; + private JButton captionFalseButton; + private JCheckBox captionDemoCheckBox; // JFormDesigner - End of variables declaration //GEN-END:variables } diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.jfd b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.jfd index f580097b3..77ac299c7 100644 --- a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.jfd +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatMacOSTest.jfd @@ -19,13 +19,38 @@ new FormModel { }, new FormLayoutConstraints( class java.lang.String ) { "value": "West" } ) + add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class java.awt.FlowLayout ) ) { + name: "captionDemoButtonsPanel" + "visible": false + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + add( new FormComponent( "javax.swing.JButton" ) { + name: "captionTrueButton" + "text": "caption=true (clicks: 0)" + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + addEvent( new FormEvent( "java.awt.event.ActionListener", "actionPerformed", "captionTrueClicked", false ) ) + }, new FormLayoutConstraints( null ) ) + add( new FormComponent( "javax.swing.JButton" ) { + name: "captionFalseButton" + "text": "caption=false (clicks: 0)" + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + addEvent( new FormEvent( "java.awt.event.ActionListener", "actionPerformed", "captionFalseClicked", false ) ) + }, new FormLayoutConstraints( null ) ) + }, new FormLayoutConstraints( class java.lang.String ) { + "value": "East" + } ) }, new FormLayoutConstraints( class java.lang.String ) { "value": "First" } ) add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { "$layoutConstraints": "ltr,insets dialog,hidemode 3" "$columnConstraints": "[left][left][left][left]para[fill]" - "$rowConstraints": "[][][][fill][][]para[]" + "$rowConstraints": "[][][][fill][][]para[][]" } ) { name: "panel2" add( new FormComponent( "javax.swing.JCheckBox" ) { @@ -175,6 +200,16 @@ new FormModel { }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { "value": "cell 0 6" } ) + add( new FormComponent( "javax.swing.JCheckBox" ) { + name: "captionDemoCheckBox" + "text": "Show caption demo buttons" + auxiliary() { + "JavaCodeGenerator.variableLocal": false + } + addEvent( new FormEvent( "java.awt.event.ActionListener", "actionPerformed", "captionDemoChanged", false ) ) + }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { + "value": "cell 0 7 4 1" + } ) }, new FormLayoutConstraints( class java.lang.String ) { "value": "Center" } )