From e59d35d89aa68a42759f381fae7dc26a6304d17a Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 4 Mar 2026 01:46:26 +0530 Subject: [PATCH] Terminal Work In Progress --- DynamicIsland.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 20 ++- DynamicIsland/ContentView.swift | 10 ++ DynamicIsland/DynamicIslandApp.swift | 19 +- .../DynamicIslandViewCoordinator.swift | 2 +- DynamicIsland/Localizable.xcstrings | 120 +++++-------- .../components/Notch/NotchTerminalView.swift | 165 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 126 ++++++++++++- .../components/Tabs/TabSelectionView.swift | 3 + DynamicIsland/enums/generic.swift | 1 + DynamicIsland/managers/TerminalManager.swift | 157 +++++++++++++++++ DynamicIsland/models/Constants.swift | 7 + 12 files changed, 570 insertions(+), 77 deletions(-) create mode 100644 DynamicIsland/components/Notch/NotchTerminalView.swift create mode 100644 DynamicIsland/managers/TerminalManager.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index f099972..46809fe 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */ = {isa = PBXBuildFile; productRef = 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; + 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ @@ -62,6 +63,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -174,6 +176,7 @@ 7E61181E2E24071E0050006C /* SkyLightWindow */, 7E288D112F1659EF0020C117 /* AtollExtensionKit */, 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */, + 7EF19C162F577040005B9929 /* SwiftTerm */, ); productName = DynamicIsland; productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */; @@ -220,6 +223,7 @@ 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */, + 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -601,6 +605,14 @@ minimumVersion = 1.1.1; }; }; + 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; 9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/EmergeTools/Pow"; @@ -682,6 +694,11 @@ package = 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */; productName = MijickCalendarView; }; + 7EF19C162F577040005B9929 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; 9A987A0F2C73CA8D005CA465 /* Collections */ = { isa = XCSwiftPackageProductDependency; package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f72980..a7a1b40 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "802e15fea0dccd2d325dc082d8d36162007adcd2fa301070eb26448d0a2cf6c5", + "originHash" : "e56c82928835ebd09a259cfbf30486c2ebf4c785c9519f893675e244e709ec46", "pins" : [ { "identity" : "atollextensionkit", @@ -109,6 +109,15 @@ "version" : "2.7.0" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -127,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm", + "state" : { + "revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", + "version" : "1.11.2" + } + }, { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index b97b446..2573d18 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -109,6 +109,14 @@ struct ContentView: View { return CGSize(width: baseSize.width, height: resolvedHeight) } + if coordinator.currentView == .terminal { + // Dynamic height: up to terminalMaxHeightFraction of screen, min 300pt + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + let maxFraction = Defaults[.terminalMaxHeightFraction] + let terminalHeight = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) + return CGSize(width: baseSize.width, height: terminalHeight) + } + if coordinator.currentView == .extensionExperience { if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) { return CGSize(width: baseSize.width, height: preferredHeight) @@ -832,6 +840,8 @@ struct ContentView: View { NotchNotesView() case .clipboard: NotchNotesView() + case .terminal: + NotchTerminalView() case .extensionExperience: if let payload = currentExtensionTabPayload() { ExtensionNotchExperienceTabView(payload: payload) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 1143c43..e23cd9c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -311,6 +311,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } else if coordinator.currentView == .notes || coordinator.currentView == .clipboard { let preferredHeight = coordinator.notesLayoutState.preferredHeight baseSize.height = max(baseSize.height, preferredHeight) + } else if coordinator.currentView == .terminal { + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + let maxFraction = Defaults[.terminalMaxHeightFraction] + baseSize.height = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) } let adjustedContentSize = statsAdjustedNotchSize( @@ -423,8 +427,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { PrivacyIndicatorManager.shared.startMonitoring() // Observe tab changes - use immediate resize to keep the notch pinned + // Deferred to next run loop tick because @Published fires on willSet, + // so coordinator.currentView still holds the OLD value at emission time. coordinator.$currentView.sink { [weak self] _ in - self?.updateWindowSizeForTabSwitch() + DispatchQueue.main.async { + self?.updateWindowSizeForTabSwitch() + } }.store(in: &cancellables) coordinator.$notesLayoutState @@ -463,6 +471,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.debouncedUpdateWindowSize() }.store(in: &cancellables) + // Observe terminal settings changes + Defaults.publisher(.enableTerminalFeature, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.terminalMaxHeightFraction, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + MemoryUsageMonitor.shared.startMonitoring() ReminderLiveActivityManager.shared.$activeWindowReminders diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index c29ce36..d578199 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -102,7 +102,7 @@ class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() private var cancellables = Set() - private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .extensionExperience] + private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience] /// Direction of the most recent tab switch (true = forward/right, false = backward/left) @Published var tabSwitchForward: Bool = true diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9f538da..459d121 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1054,6 +1054,10 @@ } } }, + "%lld pt" : { + "comment" : "A label displaying the current font size of the terminal. The value is shown in point size.", + "isCommentAutoGenerated" : true + }, "%lld px" : { "comment" : "A label displaying the current expanded notch width value. The value is shown in pixels.", "isCommentAutoGenerated" : true, @@ -1591,10 +1595,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Display name for the \"±10 Seconds\" music skip behavior.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1911,10 +1911,6 @@ } } }, - "Accent" : { - "comment" : "Name of the Caps Lock Indicator Tint Mode option that uses the accent color.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2260,10 +2256,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the timer icon color mode that uses an adaptive gradient.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -2756,6 +2748,10 @@ } } }, + "Adds a Guake-style dropdown terminal tab. The terminal runs a shell session that persists while the notch is open." : { + "comment" : "A description of the terminal settings feature.", + "isCommentAutoGenerated" : true + }, "Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : { "localizations" : { "pt-BR" : { @@ -3853,10 +3849,6 @@ } } }, - "Apple Music" : { - "comment" : "Names for the different media player types available in the app.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4268,10 +4260,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for the \"Bar\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -8530,10 +8518,6 @@ } } }, - "Digital" : { - "comment" : "The name of the \"Digital\" style of reminder presentation.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -10016,6 +10000,9 @@ } } } + }, + "Enable it in Settings → Terminal" : { + }, "Enable media panel blur" : { "comment" : "A toggle switch that enables or disables the blur effect on the media panel.", @@ -11064,6 +11051,10 @@ } } }, + "Font size" : { + "comment" : "A label displayed above a slider that controls the font size of the terminal", + "isCommentAutoGenerated" : true + }, "Free · %@" : { "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.", "isCommentAutoGenerated" : true, @@ -11919,10 +11910,6 @@ } } }, - "Green" : { - "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -14396,6 +14383,10 @@ } } }, + "Maximum height" : { + "comment" : "A label for the terminal's maximum height setting.", + "isCommentAutoGenerated" : true + }, "Media" : { "localizations" : { "ar" : { @@ -15593,6 +15584,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17809,10 +17801,6 @@ } } }, - "Now Playing" : { - "comment" : "Localized name for each media controller type.", - "isCommentAutoGenerated" : true - }, "Off" : { "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", "isCommentAutoGenerated" : true @@ -18179,10 +18167,6 @@ } } }, - "Panel" : { - "comment" : "Display name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19707,6 +19691,18 @@ } } }, + "Restart shell" : { + "comment" : "A button label that says \"Restart shell\".", + "isCommentAutoGenerated" : true + }, + "Restart Shell" : { + "comment" : "A button that restarts the shell process. Any unsaved work in the terminal will be lost.", + "isCommentAutoGenerated" : true + }, + "Restarts the shell process. Any unsaved work in the terminal will be lost." : { + "comment" : "A footer description for the \"Restart Shell\" button in the Terminal settings view.", + "isCommentAutoGenerated" : true + }, "Restore" : { "comment" : "The text for a button that restores default settings.", "isCommentAutoGenerated" : true, @@ -19785,10 +19781,6 @@ } } }, - "Ring" : { - "comment" : "Localized name for the \"Ring\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20609,10 +20601,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -20877,6 +20865,10 @@ } } }, + "Shell path" : { + "comment" : "A setting option for the path to the shell to use in the terminal", + "isCommentAutoGenerated" : true + }, "Shortcuts" : { "localizations" : { "ar" : { @@ -21667,14 +21659,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"popover\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"Panel\" screen assistant display mode.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21737,10 +21721,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -22309,10 +22289,6 @@ } } }, - "Spotify" : { - "comment" : "Add more media player types here.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -22425,10 +22401,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23173,6 +23145,18 @@ } } }, + "Terminal" : { + "comment" : "The title of the settings view related to the terminal.", + "isCommentAutoGenerated" : true + }, + "Terminal is disabled" : { + "comment" : "A message displayed when the terminal feature is disabled.", + "isCommentAutoGenerated" : true + }, + "Terminal opacity" : { + "comment" : "A label displayed above a slider that controls the opacity of the terminal view", + "isCommentAutoGenerated" : true + }, "Text" : { "comment" : "Name of a clipboard item type that contains plain text.", "isCommentAutoGenerated" : true @@ -23778,10 +23762,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Name of the music skip behavior option that skips to the next track.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25700,10 +25680,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Name for the \"Youtube Music\" media controller type in settings.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift new file mode 100644 index 0000000..bc9068d --- /dev/null +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -0,0 +1,165 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import SwiftTerm +import Defaults + +// MARK: - NSViewRepresentable wrapper for SwiftTerm + +/// Bridges SwiftTerm's `LocalProcessTerminalView` (NSView) into SwiftUI. +/// +/// Returns `TerminalManager.containerView` (a stable NSView) so that +/// SwiftUI's view lifecycle never tears down the actual terminal. The +/// real `LocalProcessTerminalView` lives as a subview of that container +/// and survives notch close/open and tab-switch cycles. +struct TerminalNSViewRepresentable: NSViewRepresentable { + @ObservedObject var terminalManager = TerminalManager.shared + + func makeNSView(context: Context) -> NSView { + context.coordinator.terminalManager = terminalManager + terminalManager.ensureTerminalView(delegate: context.coordinator) + if !terminalManager.isProcessRunning { + terminalManager.startShellProcess() + } + return terminalManager.containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Re-mount terminal if it was restarted (generation bumped). + terminalManager.ensureTerminalView(delegate: context.coordinator) + if !terminalManager.isProcessRunning { + terminalManager.startShellProcess() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, LocalProcessTerminalViewDelegate { + var terminalManager: TerminalManager? + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) { + // Terminal resized — no action needed; SwiftTerm handles reflow. + } + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) { + Task { @MainActor in + terminalManager?.updateTitle(title) + } + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { + // Could be used for breadcrumbs in the future. + } + + func processTerminated(source: TerminalView, exitCode: Int32?) { + Task { @MainActor in + terminalManager?.processDidTerminate(exitCode: exitCode) + } + } + } +} + +// MARK: - Notch Tab View + +/// Guake-style dropdown terminal tab for the notch. +/// Dynamically sizes up to half the screen height; content scrolls when the +/// terminal buffer exceeds the visible area (handled internally by SwiftTerm). +struct NotchTerminalView: View { + @ObservedObject var terminalManager = TerminalManager.shared + @EnvironmentObject var vm: DynamicIslandViewModel + @Default(.enableTerminalFeature) var enableTerminalFeature + @State private var suppressionToken = UUID() + @State private var isSuppressing = false + + var body: some View { + VStack(spacing: 0) { + if enableTerminalFeature { + // Terminal header bar + HStack { + Image(systemName: "apple.terminal") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + + Text(terminalManager.terminalTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + // Restart button + Button { + terminalManager.restartShell() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Restart shell") + } + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 4) + + Divider() + .padding(.horizontal, 8) + + // Terminal content — .id(sessionGeneration) forces + // representable recreation only after an explicit restart. + TerminalNSViewRepresentable(terminalManager: terminalManager) + .id(terminalManager.sessionGeneration) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + .padding(.bottom, 8) + .padding(.top, 4) + .onHover { hovering in + updateSuppression(for: hovering) + } + } else { + // Feature disabled placeholder + VStack(spacing: 8) { + Image(systemName: "apple.terminal") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + + Text("Terminal is disabled") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + + Text("Enable it in Settings → Terminal") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onDisappear { + updateSuppression(for: false) + } + } + + private func updateSuppression(for hovering: Bool) { + guard hovering != isSuppressing else { return } + isSuppressing = hovering + vm.setScrollGestureSuppression(hovering, token: suppressionToken) + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 5c7a6ce..f7c0efa 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -37,6 +37,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case shelf case shortcuts case notes + case terminal case about var id: String { rawValue } @@ -62,6 +63,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .shelf: return "Shelf" case .shortcuts: return "Shortcuts" case .notes: return "Notes" + case .terminal: return "Terminal" case .about: return "About" } } @@ -87,6 +89,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .shelf: return "books.vertical" case .shortcuts: return "keyboard" case .notes: return "note.text" + case .terminal: return "apple.terminal" case .about: return "info.circle" } } @@ -112,6 +115,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .shelf: return .brown case .shortcuts: return .orange case .notes: return Color(red: 0.979, green: 0.716, blue: 0.153, opacity: 1.000) + case .terminal: return Color(red: 0.2, green: 0.8, blue: 0.4) case .about: return .secondary } } @@ -421,6 +425,7 @@ struct SettingsView: View { .downloads, .shelf, .shortcuts, + .terminal, .extensions, .about ] @@ -781,13 +786,20 @@ struct SettingsView: View { SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show Color Picker Icon")), SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"], highlightID: SettingsTab.colorPicker.highlightID(for: "Display Mode")), SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"], highlightID: SettingsTab.colorPicker.highlightID(for: "History Size")), - SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats")) + SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats")), + + // Terminal + SettingsSearchEntry(tab: .terminal, title: "Enable terminal", keywords: ["terminal", "guake", "shell"], highlightID: SettingsTab.terminal.highlightID(for: "Enable terminal")), + SettingsSearchEntry(tab: .terminal, title: "Shell path", keywords: ["shell", "zsh", "bash", "terminal"], highlightID: SettingsTab.terminal.highlightID(for: "Shell path")), + SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")), + SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), + SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")), ] } private func isTabVisible(_ tab: SettingsTab) -> Bool { switch tab { - case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes: + case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes, .terminal: return !enableMinimalisticUI default: return true @@ -873,6 +885,10 @@ struct SettingsView: View { SettingsForm(tab: .notes) { NotesSettingsView() } + case .terminal: + SettingsForm(tab: .terminal) { + TerminalSettings() + } case .about: if let controller = updaterController { SettingsForm(tab: .about) { @@ -6990,3 +7006,109 @@ struct NotesSettingsView: View { .navigationTitle("Notes") } } + +// MARK: - Terminal Settings + +struct TerminalSettings: View { + @ObservedObject var terminalManager = TerminalManager.shared + @Default(.enableTerminalFeature) var enableTerminalFeature + @Default(.terminalShellPath) var terminalShellPath + @Default(.terminalFontSize) var terminalFontSize + @Default(.terminalOpacity) var terminalOpacity + @Default(.terminalMaxHeightFraction) var terminalMaxHeightFraction + + private func highlightID(_ title: String) -> String { + SettingsTab.terminal.highlightID(for: title) + } + + private var formattedMaxHeight: String { + "\(Int(terminalMaxHeightFraction * 100))% of screen" + } + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable terminal", key: .enableTerminalFeature) + .settingsHighlight(id: highlightID("Enable terminal")) + } header: { + Text("General") + } footer: { + Text("Adds a Guake-style dropdown terminal tab. The terminal runs a shell session that persists while the notch is open.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if enableTerminalFeature { + Section { + HStack { + Text("Shell path") + Spacer() + TextField("", text: $terminalShellPath) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + .multilineTextAlignment(.trailing) + } + .settingsHighlight(id: highlightID("Shell path")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Font size") + Spacer() + Text("\(Int(terminalFontSize)) pt") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalFontSize, in: 8...24, step: 1) + .onChange(of: terminalFontSize) { _, newValue in + terminalManager.applyFontSize(newValue) + } + } + .settingsHighlight(id: highlightID("Font size")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Terminal opacity") + Spacer() + Text("\(Int(terminalOpacity * 100))%") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalOpacity, in: 0.3...1.0, step: 0.05) + .onChange(of: terminalOpacity) { _, newValue in + terminalManager.applyOpacity(newValue) + } + } + .settingsHighlight(id: highlightID("Terminal opacity")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Maximum height") + Spacer() + Text(formattedMaxHeight) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalMaxHeightFraction, in: 0.2...0.5, step: 0.05) + } + .settingsHighlight(id: highlightID("Maximum height")) + } header: { + Text("Appearance") + } + + Section { + Button("Restart Shell") { + terminalManager.restartShell() + } + .disabled(!terminalManager.isProcessRunning) + } header: { + Text("Actions") + } footer: { + Text("Restarts the shell process. Any unsaved work in the terminal will be lost.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Terminal") + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index caf7efe..03a0d09 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -83,6 +83,9 @@ struct TabSelectionView: View { let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) } + if Defaults[.enableTerminalFeature] { + tabsArray.append(TabModel(label: "Terminal", icon: "apple.terminal", view: .terminal)) + } if extensionTabsEnabled { for payload in extensionTabPayloads { guard let tab = payload.descriptor.tab else { continue } diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 7b7eb52..c7d590a 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -78,6 +78,7 @@ public enum NotchViews { case colorPicker case notes case clipboard + case terminal case extensionExperience } diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift new file mode 100644 index 0000000..b723fd9 --- /dev/null +++ b/DynamicIsland/managers/TerminalManager.swift @@ -0,0 +1,157 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import SwiftUI +import SwiftTerm +import Defaults + +/// Manages the Guake-style dropdown terminal session lifecycle. +/// The terminal is lazily created when the user first switches to the terminal tab, +/// and the process is kept alive across notch open/close cycles. +/// +/// Uses a stable `containerView` (NSView) as the host so that SwiftUI's +/// `NSViewRepresentable` lifecycle (make/update/dismantle) never tears down +/// the actual terminal. The `LocalProcessTerminalView` is added as a subview +/// of the container and survives notch close/open cycles. +@MainActor +class TerminalManager: ObservableObject { + static let shared = TerminalManager() + + /// Whether a shell process is currently running. + @Published var isProcessRunning: Bool = false + + /// The current terminal title reported by the shell. + @Published var terminalTitle: String = "Terminal" + + /// Bumped on restart so the `NSViewRepresentable` can recreate via `.id()`. + @Published var sessionGeneration: Int = 0 + + /// Stable container returned to SwiftUI — never deallocated. + let containerView: NSView = { + let v = NSView(frame: .zero) + v.autoresizingMask = [.width, .height] + v.wantsLayer = true + return v + }() + + /// The actual terminal view (child of `containerView`). + private(set) var terminalView: LocalProcessTerminalView? + + private init() {} + + // MARK: - Lifecycle + + /// Ensures the terminal view exists inside the container and returns the container. + /// Call this from the `NSViewRepresentable` wrapper. + func ensureTerminalView(delegate: LocalProcessTerminalViewDelegate) { + if let existing = terminalView, existing.superview === containerView { + // Already mounted — just re-wire the delegate in case the coordinator changed. + existing.processDelegate = delegate + return + } + + let view = LocalProcessTerminalView(frame: containerView.bounds) + view.autoresizingMask = [.width, .height] + + // Apply font size from settings + let fontSize = CGFloat(Defaults[.terminalFontSize]) + if let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) as NSFont? { + view.font = font + } + + // Apply opacity + view.layer?.opacity = Float(Defaults[.terminalOpacity]) + + // Configure the dark terminal appearance + view.nativeBackgroundColor = .black + view.nativeForegroundColor = .white + + view.processDelegate = delegate + + // Mount inside the stable container + containerView.subviews.forEach { $0.removeFromSuperview() } + containerView.addSubview(view) + terminalView = view + } + + /// Starts the shell process if not already running. + func startShellProcess() { + guard let view = terminalView, !isProcessRunning else { return } + + let shell = Defaults[.terminalShellPath] + let execName = "-" + (shell as NSString).lastPathComponent // login shell convention + + view.startProcess( + executable: shell, + args: [], + environment: buildEnvironment(), + execName: execName + ) + isProcessRunning = true + } + + /// Called when the shell process terminates. + func processDidTerminate(exitCode: Int32?) { + isProcessRunning = false + } + + /// Restarts the shell by tearing down the old terminal and creating a fresh one. + func restartShell() { + // Terminate the running process gracefully + terminalView?.terminate() + // Remove old terminal from container + terminalView?.removeFromSuperview() + terminalView = nil + isProcessRunning = false + terminalTitle = "Terminal" + // Bump generation so the SwiftUI representable re-mounts + sessionGeneration += 1 + } + + /// Updates the terminal title from the shell escape sequence. + func updateTitle(_ title: String) { + terminalTitle = title + } + + /// Updates font size on the live terminal view. + func applyFontSize(_ size: Double) { + guard let view = terminalView else { return } + if let font = NSFont.monospacedSystemFont(ofSize: CGFloat(size), weight: .regular) as NSFont? { + view.font = font + } + } + + /// Updates opacity on the live terminal view. + func applyOpacity(_ opacity: Double) { + guard let view = terminalView else { return } + view.layer?.opacity = Float(opacity) + } + + // MARK: - Environment + + /// Builds the environment for the child shell process. + private func buildEnvironment() -> [String] { + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + env["LANG"] = env["LANG"] ?? "en_US.UTF-8" + // Remove TERM_PROGRAM if set by a parent terminal + env.removeValue(forKey: "TERM_PROGRAM") + return env.map { "\($0.key)=\($0.value)" } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 252fe03..86d60b1 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -911,6 +911,13 @@ extension Defaults.Keys { static let showNetworkGraph = Key("showNetworkGraph", default: false) static let showDiskGraph = Key("showDiskGraph", default: false) + // MARK: Terminal Feature + static let enableTerminalFeature = Key("enableTerminalFeature", default: false) + static let terminalShellPath = Key("terminalShellPath", default: "/bin/zsh") + static let terminalFontSize = Key("terminalFontSize", default: 12.0) + static let terminalOpacity = Key("terminalOpacity", default: 1.0) + static let terminalMaxHeightFraction = Key("terminalMaxHeightFraction", default: 0.4) + // MARK: Timer Feature static let enableTimerFeature = Key("enableTimerFeature", default: true) static let timerDisplayMode = Key("timerDisplayMode", default: .tab)