diff --git a/CommandNotch/CommandNotch.xcodeproj/project.pbxproj b/CommandNotch/CommandNotch.xcodeproj/project.pbxproj index 9cb5476..4f3e447 100644 --- a/CommandNotch/CommandNotch.xcodeproj/project.pbxproj +++ b/CommandNotch/CommandNotch.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */; }; 1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */; }; 2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */; }; + 21373D6E9C2F34FD63CEC6A5 /* TerminalScrollCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */; }; 2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */; }; 23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */; }; 26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */; }; @@ -22,6 +23,7 @@ 2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */; }; 34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */; }; 3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */; }; + 40183737D8C237022D0882FD /* TerminalScrollbackEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */; }; 4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */; }; 4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6C98C406899F4B242075AF /* AppSettingsStore.swift */; }; 4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */; }; @@ -56,6 +58,7 @@ D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */; }; DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3125FD3DC55420122CF85D80 /* SettingsView.swift */; }; DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */; }; + DD116B0FCC341D66F1534EC4 /* TerminalScrollbackEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */; }; E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */; }; E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */; }; EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */; }; @@ -82,6 +85,7 @@ /* Begin PBXFileReference section */ 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = ""; }; + 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollbackEstimator.swift; sourceTree = ""; }; 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = ""; }; 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = ""; }; @@ -91,6 +95,7 @@ 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = ""; }; 3F57837A7115DEEE11E14B40 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = ""; }; 3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = ""; }; + 40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollCoordinatorTests.swift; sourceTree = ""; }; 48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; 496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 49E1791BB45E1505500ACC67 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = ""; }; @@ -124,6 +129,7 @@ CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = ""; }; D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = ""; }; D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = ""; }; + D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollbackEstimatorTests.swift; sourceTree = ""; }; DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = ""; }; DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = ""; }; E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = ""; }; @@ -165,6 +171,7 @@ 7181BB1F3926B457445105E5 /* ScreenContext.swift */, AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */, 567E85A2ED628460CEC760DB /* TerminalManager.swift */, + 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */, 49E1791BB45E1505500ACC67 /* TerminalSession.swift */, CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */, 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */, @@ -218,6 +225,8 @@ A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */, EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */, C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */, + D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */, + 40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */, D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */, 591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */, 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */, @@ -370,7 +379,6 @@ }; }; buildConfigurationList = C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -383,6 +391,7 @@ 28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; + productRefGroup = B269158E04E8E603B61448F0 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -423,6 +432,8 @@ D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */, 8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */, CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */, + 21373D6E9C2F34FD63CEC6A5 /* TerminalScrollCoordinatorTests.swift in Sources */, + 40183737D8C237022D0882FD /* TerminalScrollbackEstimatorTests.swift in Sources */, 0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */, 154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */, 2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */, @@ -465,6 +476,7 @@ 88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */, DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */, 6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */, + DD116B0FCC341D66F1534EC4 /* TerminalScrollbackEstimator.swift in Sources */, 7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */, 65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */, 1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */, @@ -499,14 +511,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G698BP272N; INFOPLIST_FILE = CommandNotch/Resources/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app; PRODUCT_NAME = CommandNotch; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; }; name = Debug; @@ -598,14 +616,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G698BP272N; INFOPLIST_FILE = CommandNotch/Resources/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app; PRODUCT_NAME = CommandNotch; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; }; name = Release; diff --git a/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index bdfdd8a..181eef1 100644 Binary files a/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate and b/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist b/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist index aed665a..fbbe581 100644 --- a/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist @@ -5,33 +5,9 @@ SchemeUserState CommandNotch.xcscheme_^#shared#^_ - - orderHint - 0 - - CommandNotchTests.xcscheme_^#shared#^_ - - orderHint - 2 - - CommandNotchUITests.xcscheme_^#shared#^_ - - orderHint - 2 - + Release-CommandNotch.xcscheme_^#shared#^_ - - orderHint - 1 - - - SuppressBuildableAutocreation - - 1485207FA11756EC2DF4F08B - - primary - - + diff --git a/CommandNotch/CommandNotch/AppDelegate.swift b/CommandNotch/CommandNotch/AppDelegate.swift index 423e855..5ba6061 100644 --- a/CommandNotch/CommandNotch/AppDelegate.swift +++ b/CommandNotch/CommandNotch/AppDelegate.swift @@ -33,6 +33,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { observeSizePreferences() observeFontSizeChanges() observeTerminalThemeChanges() + observeTerminalScrollbackChanges() applyUITestLaunchBehaviorIfNeeded() } @@ -90,6 +91,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { .store(in: &cancellables) } + private func observeTerminalScrollbackChanges() { + settingsController.$settings + .map(\.terminal.scrollbackLines) + .removeDuplicates() + .sink { scrollbackLines in + WorkspaceRegistry.shared.updateAllWorkspacesScrollbackLines(scrollbackLines) + } + .store(in: &cancellables) + } + private var launchArguments: [String] { ProcessInfo.processInfo.arguments } diff --git a/CommandNotch/CommandNotch/Models/AppSettings.swift b/CommandNotch/CommandNotch/Models/AppSettings.swift index 89ac030..e051aa8 100644 --- a/CommandNotch/CommandNotch/Models/AppSettings.swift +++ b/CommandNotch/CommandNotch/Models/AppSettings.swift @@ -48,6 +48,7 @@ struct AppSettings: Equatable, Codable { fontSize: NotchSettings.Defaults.terminalFontSize, shellPath: NotchSettings.Defaults.terminalShell, themeRawValue: NotchSettings.Defaults.terminalTheme, + scrollbackLines: NotchSettings.Defaults.terminalScrollbackLines, sizePresetsJSON: NotchSettings.Defaults.terminalSizePresets ), hotkeys: HotkeySettings( @@ -106,6 +107,7 @@ extension AppSettings { var fontSize: Double var shellPath: String var themeRawValue: String + var scrollbackLines: Int var sizePresetsJSON: String var theme: TerminalTheme { @@ -155,6 +157,7 @@ struct TerminalSessionConfiguration: Equatable { var fontSize: CGFloat var theme: TerminalTheme var shellPath: String + var scrollbackLines: Int } @MainActor diff --git a/CommandNotch/CommandNotch/Models/AppSettingsController.swift b/CommandNotch/CommandNotch/Models/AppSettingsController.swift index 0db8e3a..b8d15ce 100644 --- a/CommandNotch/CommandNotch/Models/AppSettingsController.swift +++ b/CommandNotch/CommandNotch/Models/AppSettingsController.swift @@ -46,7 +46,8 @@ final class AppSettingsController: ObservableObject, TerminalSessionConfiguratio TerminalSessionConfiguration( fontSize: CGFloat(settings.terminal.fontSize), theme: settings.terminal.theme, - shellPath: settings.terminal.shellPath + shellPath: settings.terminal.shellPath, + scrollbackLines: settings.terminal.scrollbackLines ) } diff --git a/CommandNotch/CommandNotch/Models/AppSettingsStore.swift b/CommandNotch/CommandNotch/Models/AppSettingsStore.swift index 74960bb..82abd5f 100644 --- a/CommandNotch/CommandNotch/Models/AppSettingsStore.swift +++ b/CommandNotch/CommandNotch/Models/AppSettingsStore.swift @@ -52,6 +52,7 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { fontSize: double(NotchSettings.Keys.terminalFontSize, default: NotchSettings.Defaults.terminalFontSize), shellPath: string(NotchSettings.Keys.terminalShell, default: NotchSettings.Defaults.terminalShell), themeRawValue: string(NotchSettings.Keys.terminalTheme, default: NotchSettings.Defaults.terminalTheme), + scrollbackLines: integer(NotchSettings.Keys.terminalScrollbackLines, default: NotchSettings.Defaults.terminalScrollbackLines), sizePresetsJSON: string(NotchSettings.Keys.terminalSizePresets, default: NotchSettings.Defaults.terminalSizePresets) ), hotkeys: .init( @@ -101,6 +102,7 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { defaults.set(settings.terminal.fontSize, forKey: NotchSettings.Keys.terminalFontSize) defaults.set(settings.terminal.shellPath, forKey: NotchSettings.Keys.terminalShell) defaults.set(settings.terminal.themeRawValue, forKey: NotchSettings.Keys.terminalTheme) + defaults.set(settings.terminal.scrollbackLines, forKey: NotchSettings.Keys.terminalScrollbackLines) defaults.set(settings.terminal.sizePresetsJSON, forKey: NotchSettings.Keys.terminalSizePresets) defaults.set(settings.hotkeys.toggle.toJSON(), forKey: NotchSettings.Keys.hotkeyToggle) diff --git a/CommandNotch/CommandNotch/Models/NotchSettings.swift b/CommandNotch/CommandNotch/Models/NotchSettings.swift index b56dabd..9a1a421 100644 --- a/CommandNotch/CommandNotch/Models/NotchSettings.swift +++ b/CommandNotch/CommandNotch/Models/NotchSettings.swift @@ -47,6 +47,7 @@ enum NotchSettings { static let terminalFontSize = "terminalFontSize" static let terminalShell = "terminalShell" static let terminalTheme = "terminalTheme" + static let terminalScrollbackLines = "terminalScrollbackLines" static let terminalSizePresets = "terminalSizePresets" static let workspaceSummaries = "workspaceSummaries" static let screenAssignments = "screenAssignments" @@ -98,6 +99,7 @@ enum NotchSettings { static let terminalFontSize: Double = 13 static let terminalShell: String = "" static let terminalTheme: String = TerminalTheme.terminalApp.rawValue + static let terminalScrollbackLines: Int = 500 static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON() // Default hotkey bindings as JSON @@ -148,6 +150,7 @@ enum NotchSettings { Keys.terminalFontSize: Defaults.terminalFontSize, Keys.terminalShell: Defaults.terminalShell, Keys.terminalTheme: Defaults.terminalTheme, + Keys.terminalScrollbackLines: Defaults.terminalScrollbackLines, Keys.terminalSizePresets: Defaults.terminalSizePresets, Keys.hotkeyToggle: Defaults.hotkeyToggle, diff --git a/CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift b/CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift new file mode 100644 index 0000000..fe5f70d --- /dev/null +++ b/CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift @@ -0,0 +1,54 @@ +import AppKit +import CoreText +import SwiftTerm + +enum TerminalScrollbackEstimator { + private static let minimumColumns = 1 + private static let minimumRows = 1 + private static let defaultBytesPerLineOverhead = 256 + + struct Estimate: Equatable { + let bytes: Int + let columns: Int + let rows: Int + + var formattedBytes: String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory) + } + } + + static func estimate( + scrollbackLines: Int, + fontSize: Double, + openWidth: Double, + openHeight: Double + ) -> Estimate { + let safeScrollbackLines = max(0, scrollbackLines) + let dimensions = terminalGridDimensions( + fontSize: fontSize, + openWidth: openWidth, + openHeight: openHeight + ) + let totalLines = safeScrollbackLines + dimensions.rows + let bytesPerCell = MemoryLayout.stride + let bytesPerLine = (dimensions.columns * bytesPerCell) + defaultBytesPerLineOverhead + let totalBytes = max(0, totalLines * bytesPerLine) + + return Estimate(bytes: totalBytes, columns: dimensions.columns, rows: dimensions.rows) + } + + private static func terminalGridDimensions( + fontSize: Double, + openWidth: Double, + openHeight: Double + ) -> (columns: Int, rows: Int) { + let font = NSFont.monospacedSystemFont(ofSize: CGFloat(max(1, fontSize)), weight: .regular) + let cellWidth = max(1, font.advancement(forGlyph: font.glyph(withName: "W")).width) + let cellHeight = max(1, ceil(CTFontGetAscent(font) + CTFontGetDescent(font) + CTFontGetLeading(font))) + + let columns = max(minimumColumns, Int(CGFloat(max(1, openWidth)) / cellWidth)) + let rows = max(minimumRows, Int(CGFloat(max(1, openHeight)) / cellHeight)) + + return (columns, rows) + } +} diff --git a/CommandNotch/CommandNotch/Models/TerminalSession.swift b/CommandNotch/CommandNotch/Models/TerminalSession.swift index 50780d4..8f19165 100644 --- a/CommandNotch/CommandNotch/Models/TerminalSession.swift +++ b/CommandNotch/CommandNotch/Models/TerminalSession.swift @@ -2,6 +2,58 @@ import AppKit import SwiftTerm import Combine +/// Tracks whether the terminal viewport should follow live output or preserve +/// the user's current scrollback position. +final class TerminalScrollCoordinator { + private let bottomThreshold: Double + private var suppressScrollTracking = false + + private(set) var followsOutput = true + private(set) var preservedScrollPosition: Double = 1 + + init(bottomThreshold: Double = 0.999) { + self.bottomThreshold = bottomThreshold + } + + func terminalDidScroll(to position: Double, canScroll: Bool) { + guard !suppressScrollTracking else { return } + + guard canScroll else { + followsOutput = true + preservedScrollPosition = 1 + return + } + + let clampedPosition = min(max(position, 0), 1) + if clampedPosition >= bottomThreshold { + followsOutput = true + preservedScrollPosition = 1 + } else { + followsOutput = false + preservedScrollPosition = clampedPosition + } + } + + func outputRestorePosition(canScroll: Bool) -> Double? { + guard canScroll, !followsOutput else { return nil } + return preservedScrollPosition + } + + @discardableResult + func userDidStartTyping() -> Bool { + let shouldJumpToBottom = !followsOutput + followsOutput = true + preservedScrollPosition = 1 + return shouldJumpToBottom + } + + func suppressTracking(_ body: () -> T) -> T { + suppressScrollTracking = true + defer { suppressScrollTracking = false } + return body() + } +} + /// Wraps a single SwiftTerm TerminalView + LocalProcess pair. @MainActor class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate { @@ -12,7 +64,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon private var keyEventMonitor: Any? private let backgroundColor = NSColor.black private let configuredShellPath: String + private var scrollbackLines: Int private let launchDirectory: String + private let scrollCoordinator = TerminalScrollCoordinator() @Published var title: String = "shell" @Published var isRunning: Bool = true @@ -22,11 +76,13 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? = nil, startImmediately: Bool = true ) { terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) configuredShellPath = shellPath + self.scrollbackLines = max(0, scrollbackLines) launchDirectory = Self.resolveInitialDirectory(initialDirectory) currentDirectory = launchDirectory super.init() @@ -36,6 +92,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) terminalView.font = font applyTheme(theme) + updateScrollbackLines(self.scrollbackLines) installCommandArrowMonitor() if startImmediately { @@ -128,6 +185,12 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon terminalView.installColors(theme.ansiColors) } + func updateScrollbackLines(_ scrollbackLines: Int) { + let sanitizedScrollbackLines = max(0, scrollbackLines) + self.scrollbackLines = sanitizedScrollbackLines + terminalView.getTerminal().changeHistorySize(sanitizedScrollbackLines) + } + func terminate() { process?.terminate() process = nil @@ -142,7 +205,16 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon nonisolated func dataReceived(slice: ArraySlice) { let data = slice - Task { @MainActor in self.terminalView.feed(byteArray: data) } + Task { @MainActor in + if let restorePosition = self.scrollCoordinator.outputRestorePosition(canScroll: self.terminalView.canScroll) { + self.scrollCoordinator.suppressTracking { + self.terminalView.feed(byteArray: data) + self.terminalView.scroll(toPosition: restorePosition) + } + } else { + self.terminalView.feed(byteArray: data) + } + } } nonisolated func getWindowSize() -> winsize { @@ -155,6 +227,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon // MARK: - TerminalViewDelegate func send(source: TerminalView, data: ArraySlice) { + if scrollCoordinator.userDidStartTyping() { + terminalView.scroll(toPosition: 1) + } process?.send(data: data) } @@ -179,7 +254,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon currentDirectory = normalizedDirectory } - func scrolled(source: TerminalView, position: Double) {} + func scrolled(source: TerminalView, position: Double) { + scrollCoordinator.terminalDidScroll(to: position, canScroll: source.canScroll) + } func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} func clipboardCopy(source: TerminalView, content: Data) { diff --git a/CommandNotch/CommandNotch/Models/WorkspaceController.swift b/CommandNotch/CommandNotch/Models/WorkspaceController.swift index 732b808..fc63b4b 100644 --- a/CommandNotch/CommandNotch/Models/WorkspaceController.swift +++ b/CommandNotch/CommandNotch/Models/WorkspaceController.swift @@ -7,6 +7,7 @@ protocol TerminalSessionFactoryType { fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession } @@ -16,12 +17,14 @@ struct LiveTerminalSessionFactory: TerminalSessionFactoryType { fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { TerminalSession( fontSize: fontSize, theme: theme, shellPath: shellPath, + scrollbackLines: scrollbackLines, initialDirectory: initialDirectory ) } @@ -106,6 +109,7 @@ final class WorkspaceController: ObservableObject { fontSize: config.fontSize, theme: config.theme, shellPath: config.shellPath, + scrollbackLines: config.scrollbackLines, initialDirectory: activeTab?.currentDirectory ) @@ -187,4 +191,10 @@ final class WorkspaceController: ObservableObject { tab.applyTheme(theme) } } + + func updateAllScrollbackLines(_ scrollbackLines: Int) { + for tab in tabs { + tab.updateScrollbackLines(scrollbackLines) + } + } } diff --git a/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift b/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift index caf893c..0387426 100644 --- a/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift +++ b/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift @@ -157,6 +157,12 @@ final class WorkspaceRegistry: ObservableObject { } } + func updateAllWorkspacesScrollbackLines(_ scrollbackLines: Int) { + for controller in controllers.values { + controller.updateAllScrollbackLines(scrollbackLines) + } + } + private func resolvedWorkspaceName(from proposedName: String?) -> String { let trimmed = proposedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmed.isEmpty { diff --git a/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift b/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift index 5662940..d802cd6 100644 --- a/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift +++ b/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift @@ -1,6 +1,8 @@ import SwiftUI struct TerminalSettingsView: View { + private static let scrollbackRange = 0...1_000_000 + @ObservedObject private var settingsController = AppSettingsController.shared @State private var sizePresets: [TerminalSizePreset] = [] @@ -40,6 +42,34 @@ struct TerminalSettingsView: View { .foregroundStyle(.secondary) } + Section("Scrollback") { + HStack { + Text("Lines") + Spacer() + TextField( + "Scrollback lines", + value: scrollbackBinding, + format: .number.grouping(.automatic) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 140) + } + + Stepper( + value: scrollbackBinding, + in: Self.scrollbackRange, + step: scrollbackStepSize + ) { + Text("\(settingsController.settings.terminal.scrollbackLines.formatted()) lines") + .monospacedDigit() + } + + let estimate = scrollbackEstimate + Text("Based on the current terminal size, this may use ~\(estimate.formattedBytes) of RAM (\(estimate.columns) columns x \(estimate.rows) rows visible).") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Size Presets") { ForEach($sizePresets) { $preset in TerminalSizePresetEditor( @@ -94,6 +124,39 @@ struct TerminalSettingsView: View { sizePresets.removeAll { $0.id == id } } + private var scrollbackBinding: Binding { + Binding( + get: { settingsController.settings.terminal.scrollbackLines }, + set: { newValue in + let clampedValue = min(max(Self.scrollbackRange.lowerBound, newValue), Self.scrollbackRange.upperBound) + settingsController.update { + $0.terminal.scrollbackLines = clampedValue + } + } + ) + } + + private var scrollbackEstimate: TerminalScrollbackEstimator.Estimate { + TerminalScrollbackEstimator.estimate( + scrollbackLines: settingsController.settings.terminal.scrollbackLines, + fontSize: settingsController.settings.terminal.fontSize, + openWidth: settingsController.settings.display.openWidth, + openHeight: settingsController.settings.display.openHeight + ) + } + + private var scrollbackStepSize: Int { + let lines = settingsController.settings.terminal.scrollbackLines + switch lines { + case ..<10_000: + return 500 + case ..<100_000: + return 5_000 + default: + return 10_000 + } + } + private func applyPreset(_ preset: TerminalSizePreset) { settingsController.update { $0.display.openWidth = preset.width diff --git a/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift b/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift index a104004..13e8a73 100644 --- a/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift +++ b/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift @@ -7,11 +7,13 @@ final class AppSettingsControllerTests: XCTestCase { let store = InMemoryAppSettingsStore() var settings = AppSettings.default settings.terminal.shellPath = "/opt/homebrew/bin/fish" + settings.terminal.scrollbackLines = 12_000 store.storedSettings = settings let controller = AppSettingsController(store: store) XCTAssertEqual(controller.terminalSessionConfiguration.shellPath, "/opt/homebrew/bin/fish") + XCTAssertEqual(controller.terminalSessionConfiguration.scrollbackLines, 12_000) } func testTerminalSizePresetsDecodeFromTypedSettings() { diff --git a/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift b/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift index 4fcb853..a7ffaaf 100644 --- a/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift +++ b/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift @@ -26,6 +26,7 @@ final class AppSettingsStoreTests: XCTestCase { settings.appearance.blurRadius = 4.5 settings.terminal.fontSize = 16 settings.terminal.themeRawValue = TerminalTheme.dracula.rawValue + settings.terminal.scrollbackLines = 25_000 settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets([ TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4)) ]) diff --git a/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift b/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift index 8c366c7..12773d2 100644 --- a/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift +++ b/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift @@ -306,7 +306,7 @@ private final class TestAppSettingsStore: AppSettingsStoreType { } private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigurationProviding { - let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "") + let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "", scrollbackLines: 500) let hotkeySettings = AppSettings.default.hotkeys let terminalSizePresets = TerminalSizePresetStore.loadDefaults() } @@ -317,6 +317,7 @@ private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactor fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { fatalError("ScreenRegistryTests should not create live terminal sessions.") diff --git a/CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift b/CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift new file mode 100644 index 0000000..508bc7b --- /dev/null +++ b/CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import CommandNotch + +final class TerminalScrollCoordinatorTests: XCTestCase { + func testScrollAwayFromBottomDisablesOutputFollow() { + let coordinator = TerminalScrollCoordinator() + + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + XCTAssertFalse(coordinator.followsOutput) + XCTAssertEqual(coordinator.outputRestorePosition(canScroll: true) ?? .nan, 0.42, accuracy: 0.0001) + } + + func testTypingReEnablesFollowAndRequestsJumpToBottom() { + let coordinator = TerminalScrollCoordinator() + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + let shouldJump = coordinator.userDidStartTyping() + + XCTAssertTrue(shouldJump) + XCTAssertTrue(coordinator.followsOutput) + XCTAssertNil(coordinator.outputRestorePosition(canScroll: true)) + } + + func testScrollingBackToBottomReEnablesOutputFollow() { + let coordinator = TerminalScrollCoordinator() + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + coordinator.terminalDidScroll(to: 1, canScroll: true) + + XCTAssertTrue(coordinator.followsOutput) + XCTAssertNil(coordinator.outputRestorePosition(canScroll: true)) + } + + func testSuppressedTrackingIgnoresProgrammaticScrollUpdates() { + let coordinator = TerminalScrollCoordinator() + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + coordinator.suppressTracking { + coordinator.terminalDidScroll(to: 1, canScroll: true) + } + + XCTAssertFalse(coordinator.followsOutput) + XCTAssertEqual(coordinator.outputRestorePosition(canScroll: true) ?? .nan, 0.42, accuracy: 0.0001) + } +} diff --git a/CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift b/CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift new file mode 100644 index 0000000..6621e84 --- /dev/null +++ b/CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import CommandNotch + +final class TerminalScrollbackEstimatorTests: XCTestCase { + func testEstimateIncreasesAsScrollbackGrows() { + let small = TerminalScrollbackEstimator.estimate( + scrollbackLines: 5_000, + fontSize: 13, + openWidth: 640, + openHeight: 350 + ) + let large = TerminalScrollbackEstimator.estimate( + scrollbackLines: 100_000, + fontSize: 13, + openWidth: 640, + openHeight: 350 + ) + + XCTAssertGreaterThan(large.bytes, small.bytes) + XCTAssertGreaterThan(large.columns, 0) + XCTAssertGreaterThan(large.rows, 0) + } + + func testEstimateClampsNegativeScrollbackToZero() { + let estimate = TerminalScrollbackEstimator.estimate( + scrollbackLines: -1_000, + fontSize: 13, + openWidth: 640, + openHeight: 350 + ) + + XCTAssertGreaterThan(estimate.bytes, 0) + } +} diff --git a/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift b/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift index c1c2a4e..b135fb9 100644 --- a/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift +++ b/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift @@ -191,7 +191,7 @@ private final class InMemoryWorkspaceStore: WorkspaceStoreType { } private final class TestSettingsProvider: TerminalSessionConfigurationProviding { - let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "") + let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "", scrollbackLines: 500) let hotkeySettings = AppSettings.default.hotkeys let terminalSizePresets = TerminalSizePresetStore.loadDefaults() } @@ -204,6 +204,7 @@ private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { requestedDirectories.append(initialDirectory) @@ -211,6 +212,7 @@ private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType fontSize: fontSize, theme: theme, shellPath: shellPath, + scrollbackLines: scrollbackLines, initialDirectory: initialDirectory, startImmediately: false ) @@ -223,6 +225,7 @@ private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType { fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { fatalError("WorkspaceRegistryTests should not create live terminal sessions.")