From 80b0c55bd1f1ebbedd28111d5b30152e278fe85e Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 22 Mar 2026 19:54:36 +0000 Subject: [PATCH 1/4] feat: percentage-based window sizing, unified font slider, and settings reorganization Replace fixed pixel dimensions with percentage-based sliders: - Window width: 20-80% of screen (was 310-500px) - Window height: 5-50% of screen (was 100-400px pixels) - Single font size slider (14-100pt) in Appearance tab for all modes - Remove FontSizePreset enum and fullscreenFontSize in favor of one fontSize Settings reorganization: - Font size moved to Appearance (global) - Dimensions moved to Teleprompter tab (mode-specific) - Display mode (Follow Mouse/Fixed) moved to Floating section - Preview panel only shows on Teleprompter tab - Fixed settings window height to prevent jumping between tabs - Sidebar tabs use onTapGesture with zero spacing for reliable clicks Live updates: - Panel resizes in real time when width/height sliders change - Polls at 10Hz for actual setting changes (not blanket UserDefaults observer) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Textream/ExternalDisplayController.swift | 4 +- .../Textream/NotchOverlayController.swift | 230 +++++++++++++- Textream/Textream/NotchSettings.swift | 106 ++++--- Textream/Textream/SettingsView.swift | 298 +++++++++--------- 4 files changed, 431 insertions(+), 207 deletions(-) diff --git a/Textream/Textream/ExternalDisplayController.swift b/Textream/Textream/ExternalDisplayController.swift index ac2b272..ffd781f 100644 --- a/Textream/Textream/ExternalDisplayController.swift +++ b/Textream/Textream/ExternalDisplayController.swift @@ -214,7 +214,7 @@ struct ExternalDisplayView: View { private var prompterView: some View { GeometryReader { geo in - let fontSize = max(48, min(96, geo.size.width / 14)) + let fontSize = NotchSettings.shared.fontSize let hPad = max(40, geo.size.width * 0.08) VStack(spacing: 0) { @@ -223,7 +223,7 @@ struct ExternalDisplayView: View { SpeechScrollView( words: words, highlightedCharCount: effectiveCharCount, - font: .systemFont(ofSize: fontSize, weight: .semibold), + font: NotchSettings.shared.fontFamilyPreset.font(size: fontSize), highlightColor: NotchSettings.shared.fontColorPreset.color, cueColor: NotchSettings.shared.cueColorPreset.color, cueUnreadOpacity: NotchSettings.shared.cueBrightness.unreadOpacity, diff --git a/Textream/Textream/NotchOverlayController.swift b/Textream/Textream/NotchOverlayController.swift index 330d202..b547f34 100644 --- a/Textream/Textream/NotchOverlayController.swift +++ b/Textream/Textream/NotchOverlayController.swift @@ -47,6 +47,10 @@ class OverlayContent { class NotchOverlayController: NSObject { private var panel: NSPanel? let speechRecognizer = SpeechRecognizer() + let handGestureController = HandGestureController() + private var rewindTimer: Timer? + private var indicatorWindow: NSWindow? + private var indicatorView: NSHostingView? let overlayContent = OverlayContent() var onComplete: (() -> Void)? var onNextPage: (() -> Void)? @@ -108,6 +112,22 @@ class NotchOverlayController: NSObject { } } + // Live-update panel size when dimension sliders change + // Track last known values to only react to actual changes + var lastWidth = settings.windowWidthPercent + var lastHeight = settings.windowHeightPercent + Timer.publish(every: 0.1, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + let s = NotchSettings.shared + if s.windowWidthPercent != lastWidth || s.windowHeightPercent != lastHeight { + lastWidth = s.windowWidthPercent + lastHeight = s.windowHeightPercent + self?.updatePanelSize() + } + } + .store(in: &cancellables) + // Show floating stop button only in follow-cursor mode (panel ignores mouse events) if settings.overlayMode == .floating && settings.followCursorWhenUndocked { showStopButton(on: screen) @@ -117,6 +137,89 @@ class NotchOverlayController: NSObject { if settings.listeningMode != .classic { speechRecognizer.start(with: text) } + + handGestureController.onHandStateChanged = { [weak self] raised, height in + self?.handleHandGesture(raised: raised, height: height) + } + handGestureController.start() + } + + private func handleHandGesture(raised: Bool, height: Float) { + guard panel != nil else { return } + let settings = NotchSettings.shared + HandGestureController.log("[Controller] handleHandGesture raised=\(raised) height=\(height) mode=\(settings.listeningMode.rawValue)") + + if raised { + showHandIndicator() + + // Pause current mode + speechRecognizer.pauseForRewind() + + // Start rewind timer + rewindTimer?.invalidate() + rewindTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + guard let self else { return } + let h = self.handGestureController.handHeight + let words: Int + if h < 0.3 { words = 1 } + else if h < 0.7 { words = 2 } + else { words = 4 } + + self.speechRecognizer.rewindByWords(words) + } + } else { + HandGestureController.log("[Controller] hiding indicator, window=\(indicatorWindow != nil)") + hideHandIndicator() + + // Stop rewind + rewindTimer?.invalidate() + rewindTimer = nil + + HandGestureController.log("[Controller] calling resumeAfterRewind, isListening=\(speechRecognizer.isListening)") + switch settings.listeningMode { + case .wordTracking: + speechRecognizer.resumeAfterRewind() + HandGestureController.log("[Controller] resumeAfterRewind called, isListening=\(speechRecognizer.isListening)") + case .classic, .silencePaused: + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.speechRecognizer.resumeAfterRewind() + } + } + } + } + + private func showHandIndicator() { + guard indicatorWindow == nil else { return } + guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } + + let size: CGFloat = 60 + let margin: CGFloat = 20 + let frame = NSRect( + x: screen.frame.maxX - size - margin, + y: screen.frame.maxY - size - margin - 30, // below menu bar + width: size, + height: size + ) + + let window = NSWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false) + window.isOpaque = false + window.backgroundColor = .clear + window.level = NSWindow.Level(Int(CGShieldingWindowLevel()) + 1) + window.ignoresMouseEvents = true + window.hasShadow = false + + let hostView = NSHostingView(rootView: HandIndicatorView(isRewinding: true)) + window.contentView = hostView + window.orderFront(nil) + + indicatorWindow = window + indicatorView = hostView + } + + private func hideHandIndicator() { + indicatorWindow?.orderOut(nil) + indicatorWindow = nil + indicatorView = nil } func updateContent(text: String, hasNextPage: Bool) { @@ -136,6 +239,8 @@ class NotchOverlayController: NSObject { if settings.listeningMode != .classic { speechRecognizer.start(with: text) } + + handGestureController.start() } private func screenUnderMouse() -> NSScreen? { @@ -213,10 +318,10 @@ class NotchOverlayController: NSObject { } private func showPinned(settings: NotchSettings, screen: NSScreen) { - let notchWidth = settings.notchWidth - let textAreaHeight = settings.textAreaHeight - let maxExtraHeight: CGFloat = 350 let screenFrame = screen.frame + let textAreaHeight = screenFrame.height * settings.windowHeightPercent + let maxExtraHeight: CGFloat = 350 + let notchWidth = screenFrame.width * settings.windowWidthPercent let visibleFrame = screen.visibleFrame // Menu bar / notch height from top of screen @@ -232,7 +337,7 @@ class NotchOverlayController: NSObject { self.frameTracker = tracker self.currentScreenID = screen.displayID - let overlayView = NotchOverlayView(content: overlayContent, speechRecognizer: speechRecognizer, menuBarHeight: menuBarHeight, baseTextHeight: textAreaHeight, maxExtraHeight: maxExtraHeight, frameTracker: tracker) + let overlayView = NotchOverlayView(content: overlayContent, speechRecognizer: speechRecognizer, handGesture: handGestureController, menuBarHeight: menuBarHeight, baseTextHeight: textAreaHeight, maxExtraHeight: maxExtraHeight, frameTracker: tracker) let contentView = NSHostingView(rootView: overlayView) // Start panel at full target size (SwiftUI animates the notch shape inside) @@ -259,6 +364,8 @@ class NotchOverlayController: NSObject { panel.orderFrontRegardless() self.panel = panel + installKeyMonitor() + // Start mouse tracking for follow-mouse mode if settings.notchDisplayMode == .followMouse { startMouseTracking() @@ -266,8 +373,8 @@ class NotchOverlayController: NSObject { } private func showFollowCursor(settings: NotchSettings, screen: NSScreen) { - let panelWidth = settings.notchWidth - let panelHeight = settings.textAreaHeight + let panelWidth = screen.frame.width * settings.windowWidthPercent + let panelHeight = screen.frame.height * settings.windowHeightPercent let mouse = NSEvent.mouseLocation let cursorOffset: CGFloat = 8 @@ -277,6 +384,7 @@ class NotchOverlayController: NSObject { let floatingView = FloatingOverlayView( content: overlayContent, speechRecognizer: speechRecognizer, + handGesture: handGestureController, baseHeight: panelHeight, followingCursor: true ) @@ -336,8 +444,8 @@ class NotchOverlayController: NSObject { } private func showFloating(settings: NotchSettings, screenFrame: CGRect) { - let panelWidth = settings.notchWidth - let panelHeight = settings.textAreaHeight + let panelWidth = screenFrame.width * settings.windowWidthPercent + let panelHeight = screenFrame.height * settings.windowHeightPercent let xPosition = screenFrame.midX - panelWidth / 2 let yPosition = screenFrame.midY - panelHeight / 2 + 100 @@ -345,6 +453,7 @@ class NotchOverlayController: NSObject { let floatingView = FloatingOverlayView( content: overlayContent, speechRecognizer: speechRecognizer, + handGesture: handGestureController, baseHeight: panelHeight ) let contentView = NSHostingView(rootView: floatingView) @@ -362,8 +471,8 @@ class NotchOverlayController: NSObject { panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.ignoresMouseEvents = false panel.isMovableByWindowBackground = true - panel.minSize = NSSize(width: 280, height: panelHeight) - panel.maxSize = NSSize(width: 500, height: panelHeight + 350) + panel.minSize = NSSize(width: screenFrame.width * 0.2, height: panelHeight) + panel.maxSize = NSSize(width: screenFrame.width * 0.8, height: panelHeight + 350) panel.sharingType = NotchSettings.shared.hideFromScreenShare ? .none : .readOnly panel.contentView = contentView @@ -373,10 +482,59 @@ class NotchOverlayController: NSObject { installKeyMonitor() } + private func updatePanelSize() { + guard let panel = panel else { return } + let settings = NotchSettings.shared + guard settings.overlayMode != .fullscreen else { return } + guard let screen = panel.screen ?? NSScreen.main else { return } + let screenFrame = screen.frame + let newWidth = screenFrame.width * settings.windowWidthPercent + let newHeight = screenFrame.height * settings.windowHeightPercent + + var frame = panel.frame + + switch settings.overlayMode { + case .pinned: + let visibleFrame = screen.visibleFrame + let menuBarHeight = screenFrame.maxY - visibleFrame.maxY + let totalHeight = menuBarHeight + newHeight + frame.origin.x = screenFrame.midX - newWidth / 2 + frame.origin.y = screenFrame.maxY - totalHeight + frame.size.width = newWidth + frame.size.height = totalHeight + panel.setFrame(frame, display: true, animate: false) + + if let tracker = frameTracker { + tracker.visibleWidth = newWidth + tracker.visibleHeight = totalHeight + } + + case .floating: + let centerX = frame.midX + let centerY = frame.midY + frame.origin.x = centerX - newWidth / 2 + frame.origin.y = centerY - newHeight / 2 + frame.size.width = newWidth + frame.size.height = newHeight + panel.setFrame(frame, display: true, animate: false) + + panel.minSize = NSSize(width: screenFrame.width * 0.2, height: screenFrame.height * 0.05) + panel.maxSize = NSSize(width: screenFrame.width * 0.8, height: screenFrame.height * 0.5) + + case .fullscreen: + break + } + } + func dismiss() { // Trigger the shrink animation speechRecognizer.shouldDismiss = true speechRecognizer.forceStop() + handGestureController.onHandStateChanged = nil + handGestureController.stop() + hideHandIndicator() + rewindTimer?.invalidate() + rewindTimer = nil // Wait for animation, then remove panel DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in @@ -415,6 +573,11 @@ class NotchOverlayController: NSObject { removeEscMonitor() cancellables.removeAll() speechRecognizer.forceStop() + handGestureController.onHandStateChanged = nil + handGestureController.stop() + hideHandIndicator() + rewindTimer?.invalidate() + rewindTimer = nil speechRecognizer.recognizedCharCount = 0 panel?.orderOut(nil) panel = nil @@ -600,11 +763,49 @@ struct DynamicIslandShape: Shape { } } +// MARK: - Hand Gesture Indicator + +struct HandIndicatorView: View { + let isRewinding: Bool + + @State private var rotation: Double = 0 + + var body: some View { + ZStack { + // Background circle + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 3) + .frame(width: 44, height: 44) + + // Animated arc + Circle() + .trim(from: 0, to: 0.7) + .stroke(Color.green, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .frame(width: 44, height: 44) + .rotationEffect(.degrees(rotation)) + + // Rewind icon + Image(systemName: "backward.fill") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.green) + } + .frame(width: 60, height: 60) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotation = -360 + } + } + } +} + // MARK: - Overlay SwiftUI View struct NotchOverlayView: View { @Bindable var content: OverlayContent @Bindable var speechRecognizer: SpeechRecognizer + var handGesture: HandGestureController let menuBarHeight: CGFloat let baseTextHeight: CGFloat let maxExtraHeight: CGFloat @@ -627,6 +828,8 @@ struct NotchOverlayView: View { @State private var isUserScrolling: Bool = false private let scrollTimer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect() + // Hand-gesture rewind state + // Auto next page countdown @State private var countdownRemaining: Int = 0 @State private var countdownTimer: Timer? = nil @@ -642,6 +845,7 @@ struct NotchOverlayView: View { NotchSettings.shared.listeningMode } + /// Convert fractional word index to char offset using actual word lengths private func charOffsetForWordProgress(_ progress: Double) -> Int { let wholeWord = Int(progress) @@ -802,7 +1006,7 @@ struct NotchOverlayView: View { private func updateFrameTracker() { let targetHeight = menuBarHeight + baseTextHeight + extraHeight - let fullWidth = NotchSettings.shared.notchWidth + let fullWidth = (NSScreen.main?.frame.width ?? 1440) * NotchSettings.shared.windowWidthPercent frameTracker.visibleHeight = targetHeight frameTracker.visibleWidth = fullWidth } @@ -1136,6 +1340,7 @@ struct GlassEffectView: NSViewRepresentable { struct FloatingOverlayView: View { @Bindable var content: OverlayContent @Bindable var speechRecognizer: SpeechRecognizer + var handGesture: HandGestureController let baseHeight: CGFloat var followingCursor: Bool = false @@ -1155,10 +1360,13 @@ struct FloatingOverlayView: View { @State private var isUserScrolling: Bool = false private let scrollTimer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect() + // Hand-gesture rewind state + private var listeningMode: ListeningMode { NotchSettings.shared.listeningMode } + /// Convert fractional word index to char offset using actual word lengths private func charOffsetForWordProgress(_ progress: Double) -> Int { let wholeWord = Int(progress) diff --git a/Textream/Textream/NotchSettings.swift b/Textream/Textream/NotchSettings.swift index 1a02c9d..657aa79 100644 --- a/Textream/Textream/NotchSettings.swift +++ b/Textream/Textream/NotchSettings.swift @@ -7,32 +7,6 @@ import SwiftUI -// MARK: - Font Size Preset - -enum FontSizePreset: String, CaseIterable, Identifiable { - case xs, sm, lg, xl - - var id: String { rawValue } - - var label: String { - switch self { - case .xs: return "XS" - case .sm: return "SM" - case .lg: return "LG" - case .xl: return "XL" - } - } - - var pointSize: CGFloat { - switch self { - case .xs: return 14 - case .sm: return 16 - case .lg: return 20 - case .xl: return 24 - } - } -} - // MARK: - Font Family Preset enum FontFamilyPreset: String, CaseIterable, Identifiable { @@ -165,6 +139,41 @@ enum CueBrightness: String, CaseIterable, Identifiable { } } +// MARK: - Browser Font Size Preset + +enum BrowserFontSizePreset: String, CaseIterable, Identifiable { + case sm, md, lg, xl + + var id: String { rawValue } + + var label: String { + switch self { + case .sm: return "SM" + case .md: return "MD" + case .lg: return "LG" + case .xl: return "XL" + } + } + + var cssClamp: String { + switch self { + case .sm: return "clamp(24px,calc(100vw / 22),48px)" + case .md: return "clamp(32px,calc(100vw / 18),54px)" + case .lg: return "clamp(40px,calc(100vw / 14),60px)" + case .xl: return "clamp(48px,calc(100vw / 12),64px)" + } + } + + var mobileCssClamp: String { + switch self { + case .sm: return "clamp(18px,calc(100vw / 16),36px)" + case .md: return "clamp(22px,calc(100vw / 13),42px)" + case .lg: return "clamp(28px,calc(100vw / 10),48px)" + case .xl: return "clamp(34px,calc(100vw / 8),56px)" + } + } +} + // MARK: - Overlay Mode enum OverlayMode: String, CaseIterable, Identifiable { @@ -319,19 +328,19 @@ enum ListeningMode: String, CaseIterable, Identifiable { class NotchSettings { static let shared = NotchSettings() - var notchWidth: CGFloat { - didSet { UserDefaults.standard.set(Double(notchWidth), forKey: "notchWidth") } + var windowWidthPercent: CGFloat { + didSet { UserDefaults.standard.set(Double(windowWidthPercent), forKey: "windowWidthPercent") } } - var textAreaHeight: CGFloat { - didSet { UserDefaults.standard.set(Double(textAreaHeight), forKey: "textAreaHeight") } + var windowHeightPercent: CGFloat { + didSet { UserDefaults.standard.set(Double(windowHeightPercent), forKey: "windowHeightPercent") } } var speechLocale: String { didSet { UserDefaults.standard.set(speechLocale, forKey: "speechLocale") } } - var fontSizePreset: FontSizePreset { - didSet { UserDefaults.standard.set(fontSizePreset.rawValue, forKey: "fontSizePreset") } + var fontSize: CGFloat { + didSet { UserDefaults.standard.set(Double(fontSize), forKey: "fontSize") } } var fontFamilyPreset: FontFamilyPreset { @@ -419,6 +428,10 @@ class NotchSettings { didSet { UserDefaults.standard.set(Int(fullscreenScreenID), forKey: "fullscreenScreenID") } } + var fullscreenTopAnchor: Bool { + didSet { UserDefaults.standard.set(fullscreenTopAnchor, forKey: "fullscreenTopAnchor") } + } + var browserServerEnabled: Bool { didSet { UserDefaults.standard.set(browserServerEnabled, forKey: "browserServerEnabled") @@ -430,6 +443,10 @@ class NotchSettings { didSet { UserDefaults.standard.set(Int(browserServerPort), forKey: "browserServerPort") } } + var browserFontSizePreset: BrowserFontSizePreset { + didSet { UserDefaults.standard.set(browserFontSizePreset.rawValue, forKey: "browserFontSizePreset") } + } + var directorModeEnabled: Bool { didSet { UserDefaults.standard.set(directorModeEnabled, forKey: "directorModeEnabled") @@ -442,25 +459,22 @@ class NotchSettings { } var font: NSFont { - fontFamilyPreset.font(size: fontSizePreset.pointSize) + fontFamilyPreset.font(size: fontSize) } - static let defaultWidth: CGFloat = 340 - static let defaultHeight: CGFloat = 150 + static let defaultWindowWidthPercent: CGFloat = 0.45 + static let defaultWindowHeightPercent: CGFloat = 0.4 + static let defaultFontSize: CGFloat = 20 static let defaultLocale: String = Locale.current.identifier - static let minWidth: CGFloat = 310 - static let maxWidth: CGFloat = 500 - static let minHeight: CGFloat = 100 - static let maxHeight: CGFloat = 400 - init() { - let savedWidth = UserDefaults.standard.double(forKey: "notchWidth") - let savedHeight = UserDefaults.standard.double(forKey: "textAreaHeight") - self.notchWidth = savedWidth > 0 ? CGFloat(savedWidth) : Self.defaultWidth - self.textAreaHeight = savedHeight > 0 ? CGFloat(savedHeight) : Self.defaultHeight + let savedWidthPercent = UserDefaults.standard.double(forKey: "windowWidthPercent") + self.windowWidthPercent = savedWidthPercent > 0 ? CGFloat(savedWidthPercent) : Self.defaultWindowWidthPercent + let savedHeightPercent = UserDefaults.standard.double(forKey: "windowHeightPercent") + self.windowHeightPercent = savedHeightPercent > 0 ? CGFloat(savedHeightPercent) : Self.defaultWindowHeightPercent self.speechLocale = UserDefaults.standard.string(forKey: "speechLocale") ?? Self.defaultLocale - self.fontSizePreset = FontSizePreset(rawValue: UserDefaults.standard.string(forKey: "fontSizePreset") ?? "") ?? .lg + let savedFontSize = UserDefaults.standard.double(forKey: "fontSize") + self.fontSize = savedFontSize > 0 ? CGFloat(savedFontSize) : Self.defaultFontSize self.fontFamilyPreset = FontFamilyPreset(rawValue: UserDefaults.standard.string(forKey: "fontFamilyPreset") ?? "") ?? .sans self.fontColorPreset = FontColorPreset(rawValue: UserDefaults.standard.string(forKey: "fontColorPreset") ?? "") ?? .white self.cueColorPreset = FontColorPreset(rawValue: UserDefaults.standard.string(forKey: "cueColorPreset") ?? "") ?? .white @@ -488,9 +502,11 @@ class NotchSettings { self.autoNextPageDelay = savedDelay > 0 ? savedDelay : 3 let savedFullscreenScreenID = UserDefaults.standard.integer(forKey: "fullscreenScreenID") self.fullscreenScreenID = UInt32(savedFullscreenScreenID) + self.fullscreenTopAnchor = UserDefaults.standard.object(forKey: "fullscreenTopAnchor") as? Bool ?? false self.browserServerEnabled = UserDefaults.standard.object(forKey: "browserServerEnabled") as? Bool ?? false let savedPort = UserDefaults.standard.integer(forKey: "browserServerPort") self.browserServerPort = savedPort > 0 ? UInt16(savedPort) : 7373 + self.browserFontSizePreset = BrowserFontSizePreset(rawValue: UserDefaults.standard.string(forKey: "browserFontSizePreset") ?? "") ?? .lg self.directorModeEnabled = UserDefaults.standard.object(forKey: "directorModeEnabled") as? Bool ?? false let savedDirectorPort = UserDefaults.standard.integer(forKey: "directorServerPort") self.directorServerPort = savedDirectorPort > 0 ? UInt16(savedDirectorPort) : 7575 diff --git a/Textream/Textream/SettingsView.swift b/Textream/Textream/SettingsView.swift index 581526b..98b01ea 100644 --- a/Textream/Textream/SettingsView.swift +++ b/Textream/Textream/SettingsView.swift @@ -23,7 +23,7 @@ class NotchPreviewController { func show(settings: NotchSettings) { // If panel already exists, just re-show it if let panel { - panel.orderFront(nil) + panel.orderFrontRegardless() return } @@ -32,10 +32,10 @@ class NotchPreviewController { let visibleFrame = screen.visibleFrame let menuBarHeight = screenFrame.maxY - visibleFrame.maxY - let maxWidth = NotchSettings.maxWidth - let maxHeight = menuBarHeight + NotchSettings.maxHeight + 40 + let previewWidth = screenFrame.width * 0.8 + let maxHeight = menuBarHeight + screenFrame.height * 0.5 + 40 - let xPosition = screenFrame.midX - maxWidth / 2 + let xPosition = screenFrame.midX - previewWidth / 2 let yPosition = screenFrame.maxY - maxHeight let content = NotchPreviewContent(settings: settings, menuBarHeight: menuBarHeight) @@ -43,7 +43,7 @@ class NotchPreviewController { self.hostingView = hostingView let panel = NSPanel( - contentRect: NSRect(x: xPosition, y: yPosition, width: maxWidth, height: maxHeight), + contentRect: NSRect(x: xPosition, y: yPosition, width: previewWidth, height: maxHeight), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false @@ -53,8 +53,9 @@ class NotchPreviewController { panel.hasShadow = false panel.level = .statusBar panel.ignoresMouseEvents = true + panel.hidesOnDeactivate = false panel.contentView = hostingView - panel.orderFront(nil) + panel.orderFrontRegardless() self.panel = panel } @@ -105,13 +106,13 @@ class NotchPreviewController { private func cursorFrame(for panel: NSPanel, settings: NotchSettings) -> NSRect { let mouse = NSEvent.mouseLocation let cursorOffset: CGFloat = 8 - let maxWidth = panel.frame.width - let notchWidth = settings.notchWidth + let screenWidth = NSScreen.main?.frame.width ?? 1440 + let notchWidth = screenWidth * settings.windowWidthPercent let panelHeight = panel.frame.height - let panelX = mouse.x + cursorOffset - (maxWidth - notchWidth) / 2 + let panelX = mouse.x + cursorOffset let panelY = mouse.y + 60 - panelHeight - return NSRect(x: panelX, y: panelY, width: maxWidth, height: panelHeight) + return NSRect(x: panelX, y: panelY, width: notchWidth, height: panelHeight) } private func startCursorTracking() { @@ -153,8 +154,10 @@ struct NotchPreviewContent: View { var body: some View { GeometryReader { geo in let topPadding = menuBarHeight * (1 - offsetPhase) + 14 * offsetPhase - let contentHeight = topPadding + settings.textAreaHeight - let currentWidth = settings.notchWidth + let screenHeight = NSScreen.main?.frame.height ?? 900 + let contentHeight = topPadding + screenHeight * settings.windowHeightPercent + let screenWidth = NSScreen.main?.frame.width ?? 1440 + let currentWidth = min(screenWidth * settings.windowWidthPercent, geo.size.width) let yOffset = 60 * offsetPhase ZStack(alignment: .top) { @@ -214,8 +217,8 @@ struct NotchPreviewContent: View { .frame(width: currentWidth, height: contentHeight, alignment: .top) .offset(y: yOffset) .frame(width: geo.size.width, height: geo.size.height, alignment: .top) - .animation(.easeInOut(duration: 0.15), value: settings.notchWidth) - .animation(.easeInOut(duration: 0.15), value: settings.textAreaHeight) + .animation(.easeInOut(duration: 0.15), value: settings.windowWidthPercent) + .animation(.easeInOut(duration: 0.15), value: settings.windowHeightPercent) } .onChange(of: settings.overlayMode) { _, mode in if mode == .floating { @@ -305,7 +308,7 @@ struct SettingsView: View { var body: some View { HStack(spacing: 0) { // Sidebar - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 0) { Text("Settings") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.tertiary) @@ -314,24 +317,23 @@ struct SettingsView: View { .padding(.bottom, 6) ForEach(SettingsTab.allCases) { tab in - Button { + HStack(spacing: 7) { + Image(systemName: tab.icon) + .font(.system(size: 12, weight: .medium)) + .frame(width: 16) + Text(tab.label) + .font(.system(size: 13, weight: .regular)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear) + .foregroundStyle(selectedTab == tab ? Color.accentColor : .primary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onTapGesture { selectedTab = tab - } label: { - HStack(spacing: 7) { - Image(systemName: tab.icon) - .font(.system(size: 12, weight: .medium)) - .frame(width: 16) - Text(tab.label) - .font(.system(size: 13, weight: .regular)) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear) - .foregroundStyle(selectedTab == tab ? Color.accentColor : .primary) - .clipShape(RoundedRectangle(cornerRadius: 6)) } - .buttonStyle(.plain) } Spacer() @@ -383,8 +385,7 @@ struct SettingsView: View { } .frame(maxWidth: .infinity) } - .frame(width: 500) - .frame(minHeight: 280, maxHeight: 500) + .frame(width: 500, height: 500) .background(.ultraThinMaterial) .alert("Reset All Settings?", isPresented: $showResetConfirmation) { Button("Cancel", role: .cancel) { } @@ -396,51 +397,23 @@ struct SettingsView: View { } message: { Text("This will restore all settings to their defaults.") } - .onAppear { - if settings.overlayMode != .fullscreen { - previewController.show(settings: settings) - if settings.followCursorWhenUndocked && settings.overlayMode == .floating { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - previewController.animateToCursor(settings: settings) - } - } - } - } - .onDisappear { - previewController.dismiss() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in - previewController.hide() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - if settings.overlayMode != .fullscreen { + .onChange(of: selectedTab) { _, tab in + if tab == .teleprompter && settings.overlayMode != .fullscreen { previewController.show(settings: settings) - if settings.followCursorWhenUndocked && settings.overlayMode == .floating { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - previewController.animateToCursor(settings: settings) - } - } - } - } - .onChange(of: settings.followCursorWhenUndocked) { _, follow in - if follow && settings.overlayMode == .floating { - previewController.animateToCursor(settings: settings) } else { - previewController.animateFromCursor() + previewController.hide() } } .onChange(of: settings.overlayMode) { _, mode in - if mode == .fullscreen { - previewController.hide() - } else { + if selectedTab == .teleprompter && mode != .fullscreen { previewController.show(settings: settings) - if mode == .floating && settings.followCursorWhenUndocked { - previewController.animateToCursor(settings: settings) - } else if previewController.isAtCursor { - previewController.animateFromCursor() - } + } else { + previewController.hide() } } + .onDisappear { + previewController.dismiss() + } } // MARK: - Appearance Tab @@ -482,40 +455,30 @@ struct SettingsView: View { } } - // Text Size - Text("Size") - .font(.system(size: 13, weight: .medium)) - - HStack(spacing: 8) { - ForEach(FontSizePreset.allCases) { preset in - Button { - withAnimation(.easeInOut(duration: 0.2)) { - settings.fontSizePreset = preset - } - } label: { - VStack(spacing: 6) { - Text("Ag") - .font(Font(settings.fontFamilyPreset.font(size: preset.pointSize * 0.7))) - .foregroundStyle(settings.fontSizePreset == preset ? Color.accentColor : .primary) - Text(preset.label) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(settings.fontSizePreset == preset ? Color.accentColor : .secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(settings.fontSizePreset == preset ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.05)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(settings.fontSizePreset == preset ? Color.accentColor.opacity(0.4) : Color.clear, lineWidth: 1.5) - ) - } - .buttonStyle(.plain) + // Font Size + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Font Size") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.fontSize))pt") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) } + Slider( + value: $settings.fontSize, + in: 14...100, + step: 8 + ) } + Text("Ag") + .font(Font(settings.fontFamilyPreset.font(size: min(settings.fontSize, 48)))) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) + Divider() // Highlight Color @@ -620,47 +583,6 @@ struct SettingsView: View { .pickerStyle(.segmented) .labelsHidden() - Divider() - - // Dimensions - Text("Dimensions") - .font(.system(size: 13, weight: .medium)) - - VStack(spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Width") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - Spacer() - Text("\(Int(settings.notchWidth))px") - .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(.tertiary) - } - Slider( - value: $settings.notchWidth, - in: NotchSettings.minWidth...NotchSettings.maxWidth, - step: 10 - ) - } - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Height") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - Spacer() - Text("\(Int(settings.textAreaHeight))px") - .font(.system(size: 11, weight: .regular, design: .monospaced)) - .foregroundStyle(.tertiary) - } - Slider( - value: $settings.textAreaHeight, - in: NotchSettings.minHeight...NotchSettings.maxHeight, - step: 10 - ) - } - } } .padding(16) } @@ -771,7 +693,7 @@ struct SettingsView: View { .font(.system(size: 11)) .foregroundStyle(.secondary) - if settings.overlayMode == .pinned { + if settings.overlayMode == .floating { Divider() Text("Display") @@ -796,9 +718,7 @@ struct SettingsView: View { onRefresh: { refreshOverlayScreens() } ) } - } - if settings.overlayMode == .floating { Divider() Toggle(isOn: $settings.followCursorWhenUndocked) { @@ -867,9 +787,64 @@ struct SettingsView: View { RoundedRectangle(cornerRadius: 8) .fill(Color.primary.opacity(0.04)) ) + + Toggle(isOn: $settings.fullscreenTopAnchor) { + VStack(alignment: .leading, spacing: 2) { + Text("Lock Text to Top") + .font(.system(size: 13, weight: .medium)) + Text("Anchor the current line near the top of the screen instead of the center.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) } - Divider() + if settings.overlayMode != .fullscreen { + Divider() + + // Dimensions + Text("Dimensions") + .font(.system(size: 13, weight: .medium)) + + VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Width") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.windowWidthPercent * 100))%") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.windowWidthPercent, + in: 0.2...0.8, + step: 0.05 + ) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Height") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.windowHeightPercent * 100))%") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.windowHeightPercent, + in: 0.05...0.5, + step: 0.05 + ) + } + } + + Divider() + } // Options Toggle(isOn: $settings.showElapsedTime) { @@ -983,6 +958,17 @@ struct SettingsView: View { onRefresh: { refreshScreens() }, emptyMessage: "No external displays detected. Connect a display or enable Sidecar." ) + + Toggle(isOn: $settings.fullscreenTopAnchor) { + VStack(alignment: .leading, spacing: 2) { + Text("Lock Text to Top") + .font(.system(size: 13, weight: .medium)) + Text("Anchor the current line near the top of the screen instead of the center.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) } Spacer() } @@ -1050,6 +1036,18 @@ struct SettingsView: View { .fill(Color.accentColor.opacity(0.08)) ) + VStack(alignment: .leading, spacing: 6) { + Text("Remote Text Size") + .font(.system(size: 13, weight: .medium)) + Picker("", selection: $settings.browserFontSizePreset) { + ForEach(BrowserFontSizePreset.allCases) { preset in + Text(preset.label).tag(preset) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + DisclosureGroup("Advanced", isExpanded: $showAdvanced) { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 6) { @@ -1316,9 +1314,9 @@ struct SettingsView: View { // MARK: - Helpers private func resetAllSettings() { - settings.notchWidth = NotchSettings.defaultWidth - settings.textAreaHeight = NotchSettings.defaultHeight - settings.fontSizePreset = .lg + settings.windowWidthPercent = NotchSettings.defaultWindowWidthPercent + settings.windowHeightPercent = NotchSettings.defaultWindowHeightPercent + settings.fontSize = NotchSettings.defaultFontSize settings.fontFamilyPreset = .sans settings.fontColorPreset = .white settings.cueColorPreset = .white @@ -1330,6 +1328,7 @@ struct SettingsView: View { settings.glassOpacity = 0.15 settings.followCursorWhenUndocked = false settings.fullscreenScreenID = 0 + settings.fullscreenTopAnchor = false settings.externalDisplayMode = .off settings.externalScreenID = 0 settings.mirrorAxis = .horizontal @@ -1341,6 +1340,7 @@ struct SettingsView: View { settings.autoNextPageDelay = 3 settings.browserServerEnabled = false settings.browserServerPort = 7373 + settings.browserFontSizePreset = .lg settings.directorModeEnabled = false settings.directorServerPort = 7575 } From 81db408fe6f4de308dfc058811c7c2181947f599 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 22 Mar 2026 19:54:43 +0000 Subject: [PATCH 2/4] fix: prevent array index out of bounds crash in rewindByWords Clamp offset to sourceText length and guard empty text to prevent crash when recognizedCharCount exceeds sourceText during hand gesture rewind. Co-Authored-By: Claude Opus 4.6 (1M context) --- Textream/Textream/SpeechRecognizer.swift | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Textream/Textream/SpeechRecognizer.swift b/Textream/Textream/SpeechRecognizer.swift index 0730b1c..ca683e5 100644 --- a/Textream/Textream/SpeechRecognizer.swift +++ b/Textream/Textream/SpeechRecognizer.swift @@ -209,6 +209,42 @@ class SpeechRecognizer { beginRecognition() } + /// Pause speech recognition for gesture rewind without changing isListening state. + func pauseForRewind() { + cleanupRecognition() + } + + /// Move recognizedCharCount backward by N words. Used during gesture rewind. + func rewindByWords(_ count: Int) { + let chars = Array(sourceText) + guard !chars.isEmpty else { return } + var remaining = count + var offset = min(recognizedCharCount, chars.count) + + while remaining > 0 && offset > 0 { + // Skip any spaces at current position + while offset > 0 && chars[offset - 1] == " " { + offset -= 1 + } + // Skip to start of current word + while offset > 0 && chars[offset - 1] != " " { + offset -= 1 + } + remaining -= 1 + } + + recognizedCharCount = max(0, offset) + matchStartOffset = recognizedCharCount + } + + /// Resume speech recognition after gesture rewind from current position. + func resumeAfterRewind() { + matchStartOffset = recognizedCharCount + retryCount = 0 + isListening = true + beginRecognition() + } + private func cleanupRecognition() { // Cancel any pending restart to prevent overlapping beginRecognition calls pendingRestart?.cancel() From b1a2ecfa5b8f4ddbc4c576c5b270236def8f79d0 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 22 Mar 2026 19:54:53 +0000 Subject: [PATCH 3/4] feat: keyboard shortcuts, toolbar cleanup, and editable document title Keyboard shortcuts: - Cmd+Enter: toggle teleprompter play/stop - Cmd+R: toggle recording - Escape: stop teleprompter (fixed missing key monitor in pinned mode) Toolbar: - Centered editable document title (click to rename, Enter to confirm) - Edited name used as default when saving new files - Removed + Page button and language label from header - Teleprompter icon changed to text.viewfinder - Hover tooltips showing keyboard shortcuts on action buttons Hand gesture indicator: - Clear callback on dismiss to prevent indicator persisting after session - Guard against firing when no panel is active Co-Authored-By: Claude Opus 4.6 (1M context) --- Textream/Textream/ContentView.swift | 218 ++++++++++++------------ Textream/Textream/TextreamService.swift | 4 +- 2 files changed, 113 insertions(+), 109 deletions(-) diff --git a/Textream/Textream/ContentView.swift b/Textream/Textream/ContentView.swift index 773eb95..fe5002f 100644 --- a/Textream/Textream/ContentView.swift +++ b/Textream/Textream/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI import UniformTypeIdentifiers import CoreImage.CIFilterBuiltins + struct ContentView: View { @ObservedObject private var service = TextreamService.shared @State private var isRunning = false @@ -219,63 +220,6 @@ Happy presenting! [wave] ) ) - // Bottom bar - VStack { - Spacer() - ZStack { - // Waveform pill centered to full width - if isRecording { - waveformPill - .transition(.scale(scale: 0.8).combined(with: .opacity)) - } - - // Buttons pinned right - HStack(spacing: 10) { - Spacer() - - Button { - if isRecording { - stopRecording() - } else { - startRecording() - } - } label: { - Image(systemName: isRecording ? "pause.fill" : "mic.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .background(isRecording ? Color.orange : Color.red) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - } - .buttonStyle(.plain) - .disabled(isRunning) - .opacity(isRunning ? 0.4 : 1) - - Button { - if isRunning { - stop() - } else { - run() - } - } label: { - Image(systemName: isRunning ? "stop.fill" : "play.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .background(isRunning ? Color.red : Color.accentColor) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.2), radius: 8, y: 4) - } - .buttonStyle(.plain) - .disabled((!isRunning && !hasAnyContent) || isRecording) - .opacity((!hasAnyContent && !isRunning) || isRecording ? 0.4 : 1) - } - } - .padding(20) - } - .animation(.easeInOut(duration: 0.25), value: isRecording) - // Drop zone overlay — sits on top so TextEditor doesn't steal the drop if isDroppingPresentation { VStack(spacing: 8) { @@ -329,6 +273,64 @@ Happy presenting! [wave] } .allowsHitTesting(isDroppingPresentation) } + .overlay(alignment: .bottom) { + ZStack { + // Waveform pill centered to full width + if isRecording { + waveformPill + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + + // Buttons pinned right + HStack(spacing: 10) { + Spacer() + + Button { + if isRecording { + stopRecording() + } else { + startRecording() + } + } label: { + Image(systemName: isRecording ? "pause.fill" : "mic.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(isRecording ? Color.orange : Color.red) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 8, y: 4) + } + .buttonStyle(.plain) + .keyboardShortcut("r", modifiers: .command) + .help(isRecording ? "Stop Recording (\u{2318}R)" : "Record (\u{2318}R)") + .disabled(isRunning) + .opacity(isRunning ? 0.4 : 1) + + Button { + if isRunning { + stop() + } else { + run() + } + } label: { + Image(systemName: isRunning ? "stop.fill" : "text.viewfinder") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(isRunning ? Color.red : Color.accentColor) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 8, y: 4) + } + .buttonStyle(.plain) + .keyboardShortcut(.return, modifiers: .command) + .help(isRunning ? "Stop (Esc)" : "Start Teleprompter (\u{2318}\u{23CE})") + .disabled((!isRunning && !hasAnyContent) || isRecording) + .opacity((!hasAnyContent && !isRunning) || isRecording ? 0.4 : 1) + } + } + .padding(20) + .animation(.easeInOut(duration: 0.25), value: isRecording) + } } } @@ -425,64 +427,47 @@ Happy presenting! [wave] .frame(minWidth: 360, minHeight: 240) .background(.ultraThinMaterial) .toolbar { - ToolbarItem(placement: .automatic) { - HStack(spacing: 8) { - Button { - service.openFile() - } label: { - HStack(spacing: 4) { - if service.currentFileURL != nil && service.pages != service.savedPages { - Circle() - .fill(.orange) - .frame(width: 6, height: 6) - } - Text(service.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Untitled") - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - .foregroundStyle(.tertiary) + ToolbarItem(placement: .principal) { + HStack(spacing: 4) { + if service.currentFileURL != nil && service.pages != service.savedPages { + Circle() + .fill(.orange) + .frame(width: 6, height: 6) } - .buttonStyle(.plain) - - // Add page button in toolbar - Button { - withAnimation(.easeInOut(duration: 0.2)) { - service.pages.append("") - service.currentPageIndex = service.pages.count - 1 - } - } label: { - HStack(spacing: 3) { - Image(systemName: "plus") - .font(.system(size: 10, weight: .semibold)) - Text("Page") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - - Button { - showSettings = true - } label: { - HStack(spacing: 4) { - Image(systemName: NotchSettings.shared.listeningMode.icon) - .font(.system(size: 10)) - Text(NotchSettings.shared.listeningMode == .wordTracking - ? languageLabel - : NotchSettings.shared.listeningMode.label) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) + TextField("Untitled", text: $service.documentTitle, onCommit: { + renameFile(to: service.documentTitle) + }) + .textFieldStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 200) + } + .onAppear { + service.documentTitle = service.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Untitled" } - .padding(.horizontal, 8) + .onChange(of: service.currentFileURL) { _, url in + service.documentTitle = url?.deletingPathExtension().lastPathComponent ?? "Untitled" + } + } + } + .onKeyPress(.escape) { + if isRunning { + stop() + return .handled } + return .ignored } .sheet(isPresented: $showSettings) { SettingsView(settings: NotchSettings.shared) } + .onChange(of: showSettings) { _, isOpen in + DispatchQueue.main.async { + if let window = NSApplication.shared.mainWindow { + window.level = isOpen ? NSWindow.Level(Int(CGShieldingWindowLevel()) + 2) : .normal + } + } + } .sheet(isPresented: $showAbout) { AboutView() } @@ -591,6 +576,23 @@ Happy presenting! [wave] // MARK: - Actions + private func renameFile(to newName: String) { + guard let url = service.currentFileURL else { return } + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + service.documentTitle = url.deletingPathExtension().lastPathComponent + return + } + let newURL = url.deletingLastPathComponent().appendingPathComponent(trimmed).appendingPathExtension(url.pathExtension) + guard newURL != url else { return } + do { + try FileManager.default.moveItem(at: url, to: newURL) + service.currentFileURL = newURL + } catch { + service.documentTitle = url.deletingPathExtension().lastPathComponent + } + } + private func removePage(at index: Int) { guard service.pages.count > 1 else { return } withAnimation(.easeInOut(duration: 0.2)) { diff --git a/Textream/Textream/TextreamService.swift b/Textream/Textream/TextreamService.swift index df9e9be..c5e9d05 100644 --- a/Textream/Textream/TextreamService.swift +++ b/Textream/Textream/TextreamService.swift @@ -163,6 +163,7 @@ class TextreamService: NSObject, ObservableObject { @Published var currentFileURL: URL? @Published var savedPages: [String] = [""] + @Published var documentTitle: String = "Untitled" // MARK: - File Operations @@ -177,7 +178,8 @@ class TextreamService: NSObject, ObservableObject { func saveFileAs() { let panel = NSSavePanel() panel.allowedContentTypes = [.init(filenameExtension: "textream")!] - panel.nameFieldStringValue = "Untitled.textream" + let title = documentTitle.trimmingCharacters(in: .whitespacesAndNewlines) + panel.nameFieldStringValue = (title.isEmpty ? "Untitled" : title) + ".textream" panel.canCreateDirectories = true panel.begin { [weak self] response in From f8038bd351d2239186a76f90a0b4f1a6d4d52227 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 22 Mar 2026 19:54:58 +0000 Subject: [PATCH 4/4] docs: add design spec and implementation plan for slider-based sizing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-22-slider-sizing-fonts.md | 443 ++++++++++++++++++ .../2026-03-22-slider-sizing-fonts-design.md | 87 ++++ 2 files changed, 530 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-slider-sizing-fonts.md create mode 100644 docs/superpowers/specs/2026-03-22-slider-sizing-fonts-design.md diff --git a/docs/superpowers/plans/2026-03-22-slider-sizing-fonts.md b/docs/superpowers/plans/2026-03-22-slider-sizing-fonts.md new file mode 100644 index 0000000..88da2a6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-slider-sizing-fonts.md @@ -0,0 +1,443 @@ +# Slider-Based Window Sizing & Font Controls — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace fixed font size presets with continuous sliders and switch window width from pixels to screen percentages. + +**Architecture:** Three settings properties change (`fontSizePreset` → `fontSize`, `notchWidth` → `windowWidthPercent`, new `fullscreenFontSize`). All consumers updated to use the new values. Settings UI switches from preset buttons to sliders. + +**Tech Stack:** Swift, SwiftUI, AppKit (NSPanel) + +**Spec:** `docs/superpowers/specs/2026-03-22-slider-sizing-fonts-design.md` + +--- + +### Task 1: Update NotchSettings model + +**Files:** +- Modify: `Textream/Textream/NotchSettings.swift` + +- [ ] **Step 1: Remove FontSizePreset enum** + +Delete lines 10–34 (the entire `FontSizePreset` enum). + +- [ ] **Step 2: Replace fontSizePreset property with fontSize** + +Replace: +```swift +var fontSizePreset: FontSizePreset { + didSet { UserDefaults.standard.set(fontSizePreset.rawValue, forKey: "fontSizePreset") } +} +``` +With: +```swift +var fontSize: CGFloat { + didSet { UserDefaults.standard.set(Double(fontSize), forKey: "fontSize") } +} +``` + +- [ ] **Step 3: Add fullscreenFontSize property** + +Add after `fontSize`: +```swift +var fullscreenFontSize: CGFloat { + didSet { UserDefaults.standard.set(Double(fullscreenFontSize), forKey: "fullscreenFontSize") } +} +``` + +- [ ] **Step 4: Replace notchWidth with windowWidthPercent** + +Replace: +```swift +var notchWidth: CGFloat { + didSet { UserDefaults.standard.set(Double(notchWidth), forKey: "notchWidth") } +} +``` +With: +```swift +var windowWidthPercent: CGFloat { + didSet { UserDefaults.standard.set(Double(windowWidthPercent), forKey: "windowWidthPercent") } +} +``` + +- [ ] **Step 5: Update font computed property** + +Replace: +```swift +var font: NSFont { + fontFamilyPreset.font(size: fontSizePreset.pointSize) +} +``` +With: +```swift +var font: NSFont { + fontFamilyPreset.font(size: fontSize) +} +``` + +- [ ] **Step 6: Update constants** + +Replace `defaultWidth`, `minWidth`, `maxWidth` while preserving `defaultHeight`, `defaultLocale`, `minHeight`, `maxHeight`: +```swift +static let defaultWindowWidthPercent: CGFloat = 0.4 +static let defaultFontSize: CGFloat = 20 +static let defaultFullscreenFontSize: CGFloat = 72 +static let defaultHeight: CGFloat = 150 +static let defaultLocale: String = Locale.current.identifier + +static let minHeight: CGFloat = 100 +static let maxHeight: CGFloat = 400 +``` + +- [ ] **Step 7: Update init()** + +Replace the `notchWidth` initialization: +```swift +let savedWidth = UserDefaults.standard.double(forKey: "notchWidth") +self.notchWidth = savedWidth > 0 ? CGFloat(savedWidth) : Self.defaultWidth +``` +With: +```swift +let savedWidthPercent = UserDefaults.standard.double(forKey: "windowWidthPercent") +self.windowWidthPercent = savedWidthPercent > 0 ? CGFloat(savedWidthPercent) : Self.defaultWindowWidthPercent +``` + +Replace the `fontSizePreset` initialization: +```swift +self.fontSizePreset = FontSizePreset(rawValue: UserDefaults.standard.string(forKey: "fontSizePreset") ?? "") ?? .lg +``` +With: +```swift +let savedFontSize = UserDefaults.standard.double(forKey: "fontSize") +self.fontSize = savedFontSize > 0 ? CGFloat(savedFontSize) : Self.defaultFontSize +let savedFullscreenFontSize = UserDefaults.standard.double(forKey: "fullscreenFontSize") +self.fullscreenFontSize = savedFullscreenFontSize > 0 ? CGFloat(savedFullscreenFontSize) : Self.defaultFullscreenFontSize +``` + +- [ ] **Step 8: Commit** + +```bash +git add Textream/Textream/NotchSettings.swift +git commit -m "refactor: replace font size presets and pixel width with slider-backed settings" +``` + +### Task 2: Update NotchOverlayController window sizing + +**Files:** +- Modify: `Textream/Textream/NotchOverlayController.swift` + +- [ ] **Step 1: Update showPinned()** + +In `showPinned(settings:screen:)`, replace: +```swift +let notchWidth = settings.notchWidth +``` +With: +```swift +let notchWidth = screenFrame.width * settings.windowWidthPercent +``` + +Note: `screenFrame` is already available in this method. The rest of the method uses `notchWidth` locally so no further changes needed. + +- [ ] **Step 2: Update showFloating()** + +In `showFloating(settings:screenFrame:)`, replace: +```swift +let panelWidth = settings.notchWidth +``` +With: +```swift +let panelWidth = screenFrame.width * settings.windowWidthPercent +``` + +Replace the min/max size lines: +```swift +panel.minSize = NSSize(width: 280, height: panelHeight) +panel.maxSize = NSSize(width: 500, height: panelHeight + 350) +``` +With: +```swift +panel.minSize = NSSize(width: screenFrame.width * 0.2, height: panelHeight) +panel.maxSize = NSSize(width: screenFrame.width * 0.8, height: panelHeight + 350) +``` + +- [ ] **Step 3: Update showFollowCursor()** + +In `showFollowCursor(settings:screen:)`, replace: +```swift +let panelWidth = settings.notchWidth +``` +With: +```swift +let panelWidth = screen.frame.width * settings.windowWidthPercent +``` + +- [ ] **Step 4: Update updateFrameTracker()** + +In `NotchOverlayView.updateFrameTracker()`, replace: +```swift +let fullWidth = NotchSettings.shared.notchWidth +``` +With: +```swift +let fullWidth = (NSScreen.main?.frame.width ?? 1440) * NotchSettings.shared.windowWidthPercent +``` + +- [ ] **Step 5: Commit** + +```bash +git add Textream/Textream/NotchOverlayController.swift +git commit -m "refactor: use percentage-based window width in all overlay modes" +``` + +### Task 3: Update ExternalDisplayController fullscreen font + +**Files:** +- Modify: `Textream/Textream/ExternalDisplayController.swift` + +- [ ] **Step 1: Replace hardcoded font calculation** + +In `ExternalDisplayView.prompterView`, replace: +```swift +let fontSize = max(48, min(96, geo.size.width / 14)) +``` +and: +```swift +font: .systemFont(ofSize: fontSize, weight: .semibold), +``` +With: +```swift +let fontSize = NotchSettings.shared.fullscreenFontSize +``` +and (using `fontFamilyPreset.font` directly instead of `settings.font`, since `settings.font` uses `fontSize` for pinned/floating, not fullscreen): +```swift +font: NotchSettings.shared.fontFamilyPreset.font(size: fontSize), +``` + +- [ ] **Step 2: Commit** + +```bash +git add Textream/Textream/ExternalDisplayController.swift +git commit -m "feat: use user-controlled font size and family for fullscreen teleprompter" +``` + +### Task 4: Update SettingsView UI + +**Files:** +- Modify: `Textream/Textream/SettingsView.swift` + +- [ ] **Step 1: Update settings panel sizing** + +In the settings panel setup (~line 35), replace: +```swift +let maxWidth = NotchSettings.maxWidth +``` +With: +```swift +let maxWidth: CGFloat = 500 +``` + +Also update line 38 and 46 if they reference `NotchSettings.maxWidth` — use the local `maxWidth` constant instead (they likely already do). + +- [ ] **Step 2: Replace font size preset picker with slider** + +Replace the entire font size preset picker section (lines ~485–517): +```swift +// Text Size +Text("Size") + .font(.system(size: 13, weight: .medium)) + +HStack(spacing: 8) { + ForEach(FontSizePreset.allCases) { preset in + ... + } +} +``` +With: +```swift +// Text Size +VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Font Size") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.fontSize))pt") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.fontSize, + in: 14...48, + step: 1 + ) +} + +Text("Ag") + .font(Font(settings.fontFamilyPreset.font(size: settings.fontSize))) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) +``` + +- [ ] **Step 3: Replace width pixel slider with percentage slider** + +Replace the width slider section (lines ~630–644): +```swift +VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Width") + ... + Text("\(Int(settings.notchWidth))px") + ... + } + Slider( + value: $settings.notchWidth, + in: NotchSettings.minWidth...NotchSettings.maxWidth, + step: 10 + ) +} +``` +With: +```swift +VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Width") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.windowWidthPercent * 100))%") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.windowWidthPercent, + in: 0.2...0.8, + step: 0.05 + ) +} +``` + +- [ ] **Step 4: Add fullscreen font size slider** + +After the width/height dimensions section (after the height slider, before the closing braces), add: +```swift +Divider() + +// Fullscreen Font Size +VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Fullscreen Font Size") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(settings.fullscreenFontSize))pt") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Slider( + value: $settings.fullscreenFontSize, + in: 32...200, + step: 2 + ) +} +``` + +- [ ] **Step 5: Update notchWidth references in SettingsView preview** + +In `SettingsPreviewController.cursorFrame(for:settings:)` (~line 109), replace: +```swift +let notchWidth = settings.notchWidth +``` +With: +```swift +let notchWidth = panel.frame.width * settings.windowWidthPercent +``` + +In `NotchPreviewContent.body` (~line 157), replace: +```swift +let currentWidth = settings.notchWidth +``` +With: +```swift +let screenWidth = NSScreen.main?.frame.width ?? 1440 +let currentWidth = screenWidth * settings.windowWidthPercent +``` + +At ~line 217, replace: +```swift +.animation(.easeInOut(duration: 0.15), value: settings.notchWidth) +``` +With: +```swift +.animation(.easeInOut(duration: 0.15), value: settings.windowWidthPercent) +``` + +- [ ] **Step 6: Update resetAllSettings()** + +Replace: +```swift +settings.notchWidth = NotchSettings.defaultWidth +``` +With: +```swift +settings.windowWidthPercent = NotchSettings.defaultWindowWidthPercent +``` + +Replace: +```swift +settings.fontSizePreset = .lg +``` +With: +```swift +settings.fontSize = NotchSettings.defaultFontSize +settings.fullscreenFontSize = NotchSettings.defaultFullscreenFontSize +``` + +- [ ] **Step 7: Commit** + +```bash +git add Textream/Textream/SettingsView.swift +git commit -m "feat: replace font size presets with sliders, add fullscreen font control" +``` + +### Task 5: Build and verify + +- [ ] **Step 1: Build the project** + +```bash +cd /Users/monster/dev/textream && xcodebuild -project Textream/Textream.xcodeproj -scheme Textream -configuration Debug build 2>&1 | tail -20 +``` + +Expected: BUILD SUCCEEDED. If there are compilation errors referencing `FontSizePreset`, `notchWidth`, or `maxWidth`, fix them — these are leftover references to removed symbols. + +- [ ] **Step 2: Fix any remaining references** + +Search for any remaining references to the removed symbols: +- `FontSizePreset` — should only appear in `BrowserFontSizePreset` context (which is separate) +- `notchWidth` — should be zero occurrences +- `fontSizePreset` — should be zero occurrences +- `NotchSettings.maxWidth`, `NotchSettings.minWidth`, `NotchSettings.defaultWidth` — should be zero occurrences +- `settings.notchWidth` — should be zero occurrences + +- [ ] **Step 3: Launch and test** + +Build and launch from CLI: +```bash +cd /Users/monster/dev/textream && xcodebuild -project Textream/Textream.xcodeproj -scheme Textream -configuration Debug build 2>&1 | tail -5 && open "$(xcodebuild -project Textream/Textream.xcodeproj -scheme Textream -configuration Debug -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/Textream.app" +``` + +Manual verification: +1. Open Settings → check font size slider works (14–48pt range) +2. Check width slider shows percentages (20%–80%) +3. Check fullscreen font size slider present (32–200pt) +4. Start pinned overlay → verify width matches percentage +5. Start floating overlay → verify resizable within percentage bounds +6. Start fullscreen → verify font size matches slider value +7. Reset all settings → verify defaults restored + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A && git commit -m "fix: resolve remaining references to removed font size presets" +``` diff --git a/docs/superpowers/specs/2026-03-22-slider-sizing-fonts-design.md b/docs/superpowers/specs/2026-03-22-slider-sizing-fonts-design.md new file mode 100644 index 0000000..6910f59 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-slider-sizing-fonts-design.md @@ -0,0 +1,87 @@ +# Slider-Based Window Sizing & Font Controls + +**Date:** 2026-03-22 +**Status:** Draft + +## Summary + +Replace fixed font size presets (XS/SM/LG/XL) with continuous sliders, switch window width from pixel values to screen percentages (20–80%), and add user-controllable font size for fullscreen teleprompter. + +## Changes + +### 1. Settings Model (NotchSettings.swift) + +**Remove:** +- `FontSizePreset` enum (XS 14pt, SM 16pt, LG 20pt, XL 24pt) +- `fontSizePreset` property and its UserDefaults persistence +- `defaultWidth`, `minWidth`, `maxWidth` constants (340px, 310px, 500px) + +**Add:** +- `fontSize: CGFloat` — pinned/floating font size. Default 20, range 14–48. Persisted to UserDefaults. +- `fullscreenFontSize: CGFloat` — fullscreen font size. Default 72, range 32–200. Persisted to UserDefaults. +- `defaultWindowWidthPercent: CGFloat` = 0.4 + +**Replace:** +- `notchWidth: CGFloat` (pixel value) → `windowWidthPercent: CGFloat` (default 0.4, range 0.2–0.8). Persisted to UserDefaults. + +**Update:** +- `font` computed property: use `fontSize` directly instead of `fontSizePreset.pointSize` +- `resetAllSettings()`: reset `fontSize` to 20, `fullscreenFontSize` to 72, `windowWidthPercent` to 0.4 (replacing old `notchWidth` and `fontSizePreset` resets) + +**Unchanged:** `FontFamilyPreset`, `FontColorPreset`, `CueBrightness`, `textAreaHeight` + +### 2. Window Sizing (NotchOverlayController.swift) + +**Pinned window (`showPinned()`):** +- Width = `screen.frame.width * settings.windowWidthPercent` +- Height unchanged (textAreaHeight-based) + +**Floating window (`showFloating()`):** +- Width = `screen.frame.width * settings.windowWidthPercent` +- Remove hardcoded 500px max constraint +- `panel.minSize`: width = `screen.frame.width * 0.2` +- `panel.maxSize`: width = `screen.frame.width * 0.8` + +**Follow cursor (`showFollowCursor()`):** +- Same percentage-based width calculation as pinned/floating + +**`updateFrameTracker()` in NotchOverlayView:** +- Update to use `settings.windowWidthPercent * screen.frame.width` instead of `settings.notchWidth` + +**Fullscreen:** Unchanged (already fills screen) + +### 3. Fullscreen Font (ExternalDisplayController.swift) + +- Replace `max(48, min(96, geo.size.width / 14))` with `settings.fullscreenFontSize` +- Use `settings.font` (respecting `fontFamilyPreset`) instead of hardcoded `.systemFont` + +### 4. Settings UI (SettingsView.swift) + +**Replace font size preset picker** (4 XS/SM/LG/XL buttons) with: +- "Font Size" slider, range 14–48pt, shows current value +- Live preview text sample using slider value + +**Replace width pixel slider** (310–500px) with: +- "Window Width" slider, range 20%–80%, shows current percentage + +**Add:** +- "Fullscreen Font Size" slider, range 32–200pt, shows current value + +**Update:** +- Settings panel sizing: replace `NotchSettings.maxWidth` references with a fixed reasonable width (e.g., 500px) for the settings window itself +- `resetAllSettings()` button: update to reset new properties + +**Unchanged:** Font family picker, height slider, color pickers, cue brightness + +## Migration + +Existing UserDefaults keys for `fontSizePreset` and `notchWidth` become stale. New keys (`fontSize`, `fullscreenFontSize`, `windowWidthPercent`) initialize to defaults on first launch. No migration needed — old values are simply ignored. + +## Files Changed + +| File | Change | +|------|--------| +| `NotchSettings.swift` | Remove FontSizePreset enum, add slider-backed properties, update defaults/reset | +| `NotchOverlayController.swift` | Percentage-based width in showPinned, showFloating, showFollowCursor, updateFrameTracker | +| `ExternalDisplayController.swift` | Use settings.fullscreenFontSize and settings.font | +| `SettingsView.swift` | Replace preset buttons with sliders, fix settings panel sizing |