From 395c9af50404f1730b9bb608974a4f27296bddcc Mon Sep 17 00:00:00 2001 From: Madeline Onassis Date: Wed, 12 Aug 2026 04:25:40 +0100 Subject: [PATCH] Allow dragging the floating window from the top and show resize cursors The floating overlay is a borderless panel, so it has no title bar to drag by and no frame to provide resize cursors. Moving it meant catching the few points of window border, and the edges gave no indication they could be dragged. Add a grab bar across the top of the window that starts a window drag, styled after the resize handle already used by the pinned overlay, and an edge overlay that shows the standard frame resize cursors on the sides and bottom. The top edge is left to the drag bar. The cursors are driven by tracking areas rather than cursor rects: the overlay is a non-activating panel that never becomes key, and cursor rects only apply to the key window. The overlay declines hit testing, so the window continues to handle the resize itself and the content underneath stays interactive. Both are skipped in follow-cursor mode, where the panel ignores mouse events and positions itself. --- .../Textream/NotchOverlayController.swift | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/Textream/Textream/NotchOverlayController.swift b/Textream/Textream/NotchOverlayController.swift index ca95e34..00322be 100644 --- a/Textream/Textream/NotchOverlayController.swift +++ b/Textream/Textream/NotchOverlayController.swift @@ -1202,6 +1202,118 @@ struct GlassEffectView: NSViewRepresentable { } } +// MARK: - Floating Window Chrome + +/// Starts a window drag from anywhere it is placed. The floating panel is +/// borderless, and its SwiftUI hosting view consumes the mouse events that +/// `isMovableByWindowBackground` would otherwise act on, so the drag has to be +/// started explicitly. +struct WindowDragArea: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { DragView() } + + func updateNSView(_ nsView: NSView, context: Context) {} + + private final class DragView: NSView { + override func mouseDown(with event: NSEvent) { + window?.performDrag(with: event) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .openHand) + } + } +} + +/// Height of the drag bar at the top of the floating window. +private let windowDragBarHeight: CGFloat = 14 + +/// Shows the standard frame resize cursors along the side and bottom edges of +/// the borderless floating panel, which has no title bar to provide them. The +/// top edge is left to the drag bar, so the side bands stop below it. +/// +/// Tracking areas are used rather than cursor rects because the panel is a +/// non-activating panel that never becomes key, and cursor rects only apply to +/// the key window. Hit testing is declined so the window keeps handling the +/// resize itself and the content underneath stays interactive. +struct WindowResizeCursorArea: NSViewRepresentable { + var thickness: CGFloat = 6 + var topInset: CGFloat = 0 + + func makeNSView(context: Context) -> NSView { + BorderView(thickness: thickness, topInset: topInset) + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + private final class BorderView: NSView { + private let thickness: CGFloat + private let topInset: CGFloat + private var cursors: [NSCursor] = [] + + init(thickness: CGFloat, topInset: CGFloat) { + self.thickness = thickness + self.topInset = topInset + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas { + removeTrackingArea(area) + } + + // AppKit view coordinates put the origin at the bottom left, so the + // drag bar at the top of the window is trimmed off the side bands. + let edge = thickness + let width = bounds.width + let sideHeight = bounds.height - topInset - edge + guard width > edge * 2, sideHeight > 0 else { return } + + let regions: [(NSRect, NSCursor.FrameResizePosition)] = [ + (NSRect(x: 0, y: 0, width: edge, height: edge), .bottomLeft), + (NSRect(x: width - edge, y: 0, width: edge, height: edge), .bottomRight), + (NSRect(x: edge, y: 0, width: width - edge * 2, height: edge), .bottom), + (NSRect(x: 0, y: edge, width: edge, height: sideHeight), .left), + (NSRect(x: width - edge, y: edge, width: edge, height: sideHeight), .right) + ] + + cursors = regions.map { NSCursor.frameResize(position: $0.1, directions: .all) } + for (index, region) in regions.enumerated() { + addTrackingArea(NSTrackingArea( + rect: region.0, + options: [.activeAlways, .mouseEnteredAndExited, .cursorUpdate], + owner: self, + userInfo: ["index": index] + )) + } + } + + private func cursor(for event: NSEvent) -> NSCursor? { + guard let index = event.trackingArea?.userInfo?["index"] as? Int, + cursors.indices.contains(index) else { return nil } + return cursors[index] + } + + override func mouseEntered(with event: NSEvent) { + cursor(for: event)?.set() + } + + override func cursorUpdate(with event: NSEvent) { + cursor(for: event)?.set() + } + + override func mouseExited(with event: NSEvent) { + NSCursor.arrow.set() + } + } +} + // MARK: - Floating Overlay View struct FloatingOverlayView: View { @@ -1282,6 +1394,11 @@ struct FloatingOverlayView: View { var body: some View { VStack(spacing: 0) { + // Follow-cursor panels ignore mouse events and reposition themselves + if !followingCursor { + windowDragBar + } + if content.showPagePicker { floatingPagePickerView } else if isDone && (listeningMode == .wordTracking || hasNextPage) { @@ -1314,6 +1431,11 @@ struct FloatingOverlayView: View { } ) .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + if !followingCursor { + WindowResizeCursorArea(topInset: windowDragBarHeight) + } + } .opacity(appeared ? 1 : 0) .scaleEffect(appeared ? 1 : 0.9) .onAppear { @@ -1368,6 +1490,19 @@ struct FloatingOverlayView: View { } } + /// Grab area for moving the window, styled after the resize handle in the + /// pinned overlay so the two read as the same kind of control. + private var windowDragBar: some View { + ZStack { + RoundedRectangle(cornerRadius: 2) + .fill(Color.white.opacity(0.25)) + .frame(width: 36, height: 4) + WindowDragArea() + } + .frame(maxWidth: .infinity) + .frame(height: windowDragBarHeight) + } + private var floatingPrompterView: some View { VStack(spacing: 0) { SpeechScrollView(