From a342e6359cfd34867112922ba0abe0870d7680a3 Mon Sep 17 00:00:00 2001 From: skobylite Date: Sat, 22 Aug 2026 15:12:12 +0100 Subject: [PATCH] Mirror the Floating Window and Fullscreen overlays Mirroring was only available on an external display in Mirror mode. A prompter rig driven from the Mac's own screen had no way to flip the text. Adds a Mirror toggle to the overlay settings for the Floating Window and Fullscreen modes, reusing the existing MirrorAxis (horizontal, vertical, both) rather than introducing a parallel set of axis settings, plus a Mirror button on the overlay itself so the flip can be changed mid-session without opening Settings. The button is a sibling of the flipped content, so it stays upright while mirroring is on. It is omitted in follow-cursor mode, whose panel ignores mouse events. Co-Authored-By: Claude Opus 5 --- .../Textream/NotchOverlayController.swift | 61 ++++++++++++++++++- Textream/Textream/NotchSettings.swift | 7 +++ Textream/Textream/SettingsView.swift | 29 +++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/Textream/Textream/NotchOverlayController.swift b/Textream/Textream/NotchOverlayController.swift index ca95e34..d146a38 100644 --- a/Textream/Textream/NotchOverlayController.swift +++ b/Textream/Textream/NotchOverlayController.swift @@ -289,7 +289,11 @@ class NotchOverlayController: NSObject { baseHeight: panelHeight, followingCursor: true ) - let contentView = NSHostingView(rootView: floatingView) + // Follow-cursor panels ignore mouse events, so the control would be + // unclickable there. + let contentView = NSHostingView( + rootView: MirroredOverlay(showsControl: false) { floatingView } + ) let panel = NSPanel( contentRect: initialFrame, @@ -321,7 +325,9 @@ class NotchOverlayController: NSObject { speechRecognizer: speechRecognizer, mirrorAxis: nil ) - let contentView = NSHostingView(rootView: fullscreenView) + let contentView = NSHostingView( + rootView: MirroredOverlay(showsControl: true) { fullscreenView } + ) let panel = NSPanel( contentRect: screenFrame, @@ -356,7 +362,9 @@ class NotchOverlayController: NSObject { speechRecognizer: speechRecognizer, baseHeight: panelHeight ) - let contentView = NSHostingView(rootView: floatingView) + let contentView = NSHostingView( + rootView: MirroredOverlay(showsControl: true) { floatingView } + ) let panel = NSPanel( contentRect: NSRect(x: xPosition, y: yPosition, width: panelWidth, height: panelHeight), @@ -1645,3 +1653,50 @@ struct FloatingOverlayView: View { .transition(.scale.combined(with: .opacity)) } } + + +// MARK: - Overlay Mirroring + +/// Flips an overlay for a teleprompter mirror rig and, where the panel accepts +/// clicks, puts a Mirror toggle on top of it. The control is a sibling of the +/// flipped content, so it stays upright and readable while mirroring is on. +struct MirroredOverlay: View { + let showsControl: Bool + @ViewBuilder let content: Content + + @State private var isHovering = false + + private var axis: MirrorAxis? { + let settings = NotchSettings.shared + return settings.mirrorOverlay ? settings.mirrorAxis : nil + } + + var body: some View { + ZStack(alignment: .topTrailing) { + content + .scaleEffect(x: axis?.scaleX ?? 1, y: axis?.scaleY ?? 1) + + if showsControl { + Button { + NotchSettings.shared.mirrorOverlay.toggle() + } label: { + Image(systemName: "arrow.left.arrow.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(axis == nil ? Color.white.opacity(0.7) : Color.yellow) + .frame(width: 26, height: 26) + .background( + Circle().fill(axis == nil + ? Color.white.opacity(0.12) + : Color.yellow.opacity(0.22)) + ) + } + .buttonStyle(.plain) + .help(axis == nil ? "Mirror the prompter" : "Stop mirroring") + .padding(10) + .opacity(isHovering ? 1 : 0.35) + .onHover { isHovering = $0 } + .animation(.easeInOut(duration: 0.15), value: isHovering) + } + } + } +} diff --git a/Textream/Textream/NotchSettings.swift b/Textream/Textream/NotchSettings.swift index b136557..40c3a51 100644 --- a/Textream/Textream/NotchSettings.swift +++ b/Textream/Textream/NotchSettings.swift @@ -394,6 +394,12 @@ class NotchSettings { didSet { UserDefaults.standard.set(mirrorAxis.rawValue, forKey: "mirrorAxis") } } + /// Mirror the Floating Window and Fullscreen overlays, using `mirrorAxis`. + /// The external display has its own Mirror mode and is unaffected. + var mirrorOverlay: Bool { + didSet { UserDefaults.standard.set(mirrorOverlay, forKey: "mirrorOverlay") } + } + var listeningMode: ListeningMode { didSet { UserDefaults.standard.set(listeningMode.rawValue, forKey: "listeningMode") } } @@ -491,6 +497,7 @@ class NotchSettings { let savedScreenID = UserDefaults.standard.integer(forKey: "externalScreenID") self.externalScreenID = UInt32(savedScreenID) self.mirrorAxis = MirrorAxis(rawValue: UserDefaults.standard.string(forKey: "mirrorAxis") ?? "") ?? .horizontal + self.mirrorOverlay = UserDefaults.standard.object(forKey: "mirrorOverlay") as? Bool ?? false self.listeningMode = ListeningMode(rawValue: UserDefaults.standard.string(forKey: "listeningMode") ?? "") ?? .wordTracking let savedSpeed = UserDefaults.standard.double(forKey: "scrollSpeed") self.scrollSpeed = savedSpeed > 0 ? savedSpeed : 3 diff --git a/Textream/Textream/SettingsView.swift b/Textream/Textream/SettingsView.swift index ad74631..1adcdfd 100644 --- a/Textream/Textream/SettingsView.swift +++ b/Textream/Textream/SettingsView.swift @@ -810,6 +810,34 @@ struct SettingsView: View { .font(.system(size: 11)) .foregroundStyle(.secondary) + if settings.overlayMode != .pinned { + Divider() + + Toggle(isOn: $settings.mirrorOverlay) { + Text("Mirror the prompter") + .font(.system(size: 13, weight: .medium)) + } + .toggleStyle(.switch) + + Text("Flips the overlay for a teleprompter mirror rig.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + if settings.mirrorOverlay { + Picker("", selection: $settings.mirrorAxis) { + ForEach(MirrorAxis.allCases) { axis in + Text(axis.label).tag(axis) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + Text(settings.mirrorAxis.description) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + if settings.overlayMode == .pinned { Divider() @@ -1416,6 +1444,7 @@ struct SettingsView: View { settings.externalDisplayMode = .off settings.externalScreenID = 0 settings.mirrorAxis = .horizontal + settings.mirrorOverlay = false settings.listeningMode = .wordTracking settings.scrollSpeed = 3 settings.showElapsedTime = true