diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift new file mode 100644 index 000000000..667f19d5c --- /dev/null +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift @@ -0,0 +1,179 @@ +// +// CodeEditWindowController+Panels.swift +// CodeEdit +// +// Created by Simon Kudsk on 11/05/2025. +// + +import SwiftUI + +extension CodeEditWindowController { + @objc + func objcToggleFirstPanel() { + toggleFirstPanel(shouldAnimate: true) + } + + /// Toggles the navigator pane, optionally without animation. + func toggleFirstPanel(shouldAnimate: Bool = true) { + guard let firstSplitView = splitViewController?.splitViewItems.first else { return } + + if shouldAnimate { + // Standard animated toggle + firstSplitView.animator().isCollapsed.toggle() + } else { + // Instant toggle (no animation) + firstSplitView.isCollapsed.toggle() + } + + splitViewController?.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed) + } + + @objc + func objcToggleLastPanel() { + toggleLastPanel(shouldAnimate: true) + } + + func toggleLastPanel(shouldAnimate: Bool = true) { + guard let lastSplitView = splitViewController?.splitViewItems.last else { + return + } + + if shouldAnimate { + // Standard animated toggle + NSAnimationContext.runAnimationGroup { _ in + lastSplitView.animator().isCollapsed.toggle() + } + } else { + // Instant toggle (no animation) + lastSplitView.isCollapsed.toggle() + } + + splitViewController?.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed) + } + + // PanelDescriptor, used for an array of panels, for use with "Hide interface". + private struct PanelDescriptor { + /// Returns the current `isCollapsed` value for the panel. + let isCollapsed: () -> Bool + /// Returns the last stored previous state (or `nil` if none). + let getPrevCollapsed: () -> Bool? + /// Stores a new previous state (`nil` to clear). + let setPrevCollapsed: (Bool?) -> Void + /// Performs the actual toggle action for the panel. + let toggle: () -> Void + } + + // The panels which "Hide interface" should interact with. + private var panels: [PanelDescriptor] { + [ + PanelDescriptor( + isCollapsed: { self.navigatorCollapsed }, + getPrevCollapsed: { self.prevNavigatorCollapsed }, + setPrevCollapsed: { self.prevNavigatorCollapsed = $0 }, + toggle: { self.toggleFirstPanel(shouldAnimate: false) } + ), + PanelDescriptor( + isCollapsed: { self.inspectorCollapsed }, + getPrevCollapsed: { self.prevInspectorCollapsed }, + setPrevCollapsed: { self.prevInspectorCollapsed = $0 }, + toggle: { self.toggleLastPanel(shouldAnimate: false) } + ), + PanelDescriptor( + isCollapsed: { self.workspace?.utilityAreaModel?.isCollapsed ?? true }, + getPrevCollapsed: { self.prevUtilityAreaCollapsed }, + setPrevCollapsed: { self.prevUtilityAreaCollapsed = $0 }, + toggle: { CommandManager.shared.executeCommand("open.drawer.no.animation") } + ), + PanelDescriptor( + isCollapsed: { self.toolbarCollapsed }, + getPrevCollapsed: { self.prevToolbarCollapsed }, + setPrevCollapsed: { self.prevToolbarCollapsed = $0 }, + toggle: { self.toggleToolbar() } + ) + ] + } + + /// Returns `true` if at least one panel that was visible is still collapsed, meaning the interface is still hidden + func isInterfaceStillHidden() -> Bool { + // Some panels do not yet have a remembered state + if panels.contains(where: { $0.getPrevCollapsed() == nil }) { + // Hidden only if all panels are collapsed + return panels.allSatisfy { $0.isCollapsed() } + } + + // All panels have a remembered state. Check if any that were visible are still collapsed + let stillHidden = panels.contains { descriptor in + guard let prev = descriptor.getPrevCollapsed() else { return false } + return !prev && descriptor.isCollapsed() + } + + // If the interface has been restored, reset the remembered states + if !stillHidden { + DispatchQueue.main.async { [weak self] in + self?.resetStoredInterfaceCollapseState() + } + } + + return stillHidden + } + + /// Function for toggling the interface elements on or off + /// + /// - Parameter shouldHide: Pass `true` to hide all interface panels (and remember their current states), + /// or `false` to restore them to how they were before hiding. + func toggleInterface(shouldHide: Bool) { + // Store the current layout before hiding + if shouldHide { + storeInterfaceCollapseState() + } + + // Iterate over all panels and update their state as needed + for panel in panels { + let targetState = determineDesiredCollapseState( + shouldHide: shouldHide, + currentlyCollapsed: panel.isCollapsed(), + previouslyCollapsed: panel.getPrevCollapsed() + ) + if panel.isCollapsed() != targetState { + panel.toggle() + } + } + } + + /// Calculates the collapse state an interface element should have after a hide / show toggle. + /// - Parameters: + /// - shouldHide: `true` when we’re hiding the whole interface. + /// - currentlyCollapsed: The panels current state + /// - previouslyCollapsed: The state we saved the last time we hid the UI, if any. + /// - Returns: `true` for visible element, `false` for collapsed element + func determineDesiredCollapseState(shouldHide: Bool, currentlyCollapsed: Bool, previouslyCollapsed: Bool?) -> Bool { + // If ShouldHide, everything should close + if shouldHide { + return true + } + + // If not hiding, and not currently collapsed, the panel should remain as such. + if !currentlyCollapsed { + return false + } + + // If the panel is currently collapsed and we are "showing" or "restoring": + // Option 1: Restore to its previously remembered state if available. + // Option 2: If no previously remembered state, default to making it visible (not collapsed). + return previouslyCollapsed ?? false + } + + /// Function for storing the current interface visibility states + func storeInterfaceCollapseState() { + for panel in panels { + panel.setPrevCollapsed(panel.isCollapsed()) + } + } + + /// Function for resetting the stored interface visibility states + func resetStoredInterfaceCollapseState() { + for panel in panels { + panel.setPrevCollapsed(nil) + } + } +} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index e7d5f0282..738c2c6b3 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift @@ -92,7 +92,7 @@ extension CodeEditWindowController { toolbarItem.toolTip = "Hide or show the Navigator" toolbarItem.isBordered = true toolbarItem.target = self - toolbarItem.action = #selector(self.toggleFirstPanel) + toolbarItem.action = #selector(self.objcToggleFirstPanel) toolbarItem.image = NSImage( systemSymbolName: "sidebar.leading", accessibilityDescription: nil @@ -106,7 +106,7 @@ extension CodeEditWindowController { toolbarItem.toolTip = "Hide or show the Inspectors" toolbarItem.isBordered = true toolbarItem.target = self - toolbarItem.action = #selector(self.toggleLastPanel) + toolbarItem.action = #selector(self.objcToggleLastPanel) toolbarItem.image = NSImage( systemSymbolName: "sidebar.trailing", accessibilityDescription: nil diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift index 3d581f079..18634b950 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift @@ -10,9 +10,15 @@ import SwiftUI import Combine final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, ObservableObject, NSWindowDelegate { - @Published var navigatorCollapsed = false - @Published var inspectorCollapsed = false - @Published var toolbarCollapsed = false + @Published var navigatorCollapsed: Bool = false + @Published var inspectorCollapsed: Bool = false + @Published var toolbarCollapsed: Bool = false + + // These variables store the state of the windows when using "Hide interface" + @Published var prevNavigatorCollapsed: Bool? + @Published var prevInspectorCollapsed: Bool? + @Published var prevUtilityAreaCollapsed: Bool? + @Published var prevToolbarCollapsed: Bool? private var panelOpen = false diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift index 88e7dbc9b..baade6dfd 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift @@ -9,26 +9,6 @@ import SwiftUI import Combine extension CodeEditWindowController { - @objc - func toggleFirstPanel() { - guard let firstSplitView = splitViewController?.splitViewItems.first else { return } - firstSplitView.animator().isCollapsed.toggle() - splitViewController?.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed) - } - - @objc - func toggleLastPanel() { - guard let lastSplitView = splitViewController?.splitViewItems.last else { - return - } - - NSAnimationContext.runAnimationGroup { _ in - lastSplitView.animator().isCollapsed.toggle() - } - - splitViewController?.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed) - } - /// These are example items that added as commands to command palette func registerCommands() { CommandManager.shared.addCommand( diff --git a/CodeEdit/Features/SplitView/Model/SplitViewItem.swift b/CodeEdit/Features/SplitView/Model/SplitViewItem.swift index 4229bf5e4..9f8521e80 100644 --- a/CodeEdit/Features/SplitView/Model/SplitViewItem.swift +++ b/CodeEdit/Features/SplitView/Model/SplitViewItem.swift @@ -45,9 +45,15 @@ class SplitViewItem: ObservableObject { /// - Parameter child: the view corresponding to the SplitViewItem. func update(child: _VariadicView.Children.Element) { self.item.canCollapse = child[SplitViewItemCanCollapseViewTraitKey.self] + let canAnimate = child[SplitViewItemCanAnimateViewTraitKey.self] DispatchQueue.main.async { self.observers = [] - self.item.animator().isCollapsed = child[SplitViewItemCollapsedViewTraitKey.self].wrappedValue + let collapsed = child[SplitViewItemCollapsedViewTraitKey.self].wrappedValue + if canAnimate { + self.item.animator().isCollapsed = collapsed + } else { + self.item.isCollapsed = collapsed + } self.item.holdingPriority = child[SplitViewHoldingPriorityTraitKey.self] self.observers = self.createObservers() } diff --git a/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift b/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift index 3df0c7828..95f4e01bb 100644 --- a/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift +++ b/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift @@ -23,6 +23,10 @@ struct SplitViewHoldingPriorityTraitKey: _ViewTraitKey { static var defaultValue: NSLayoutConstraint.Priority = .defaultLow } +struct SplitViewItemCanAnimateViewTraitKey: _ViewTraitKey { + static var defaultValue: Bool { true } +} + extension View { func collapsed(_ value: Binding) -> some View { self @@ -43,4 +47,8 @@ extension View { self ._trait(SplitViewHoldingPriorityTraitKey.self, priority) } + + func splitViewCanAnimate(_ enabled: Binding) -> some View { + self._trait(SplitViewItemCanAnimateViewTraitKey.self, enabled.wrappedValue) + } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift index 1e5c2d15d..6fbbb6810 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarToggleUtilityAreaButton.swift @@ -31,6 +31,12 @@ internal struct StatusBarToggleUtilityAreaButton: View { id: "open.drawer", command: { [weak utilityAreaViewModel] in utilityAreaViewModel?.togglePanel() } ) + CommandManager.shared.addCommand( + name: "Toggle Utility Area Without Animation", + title: "Toggle Utility Area Without Animation", + id: "open.drawer.no.animation", + command: { [weak utilityAreaViewModel] in utilityAreaViewModel?.togglePanel(animation: false) } + ) } } .onAppear { @@ -40,6 +46,12 @@ internal struct StatusBarToggleUtilityAreaButton: View { id: "open.drawer", command: { [weak utilityAreaViewModel] in utilityAreaViewModel?.togglePanel() } ) + CommandManager.shared.addCommand( + name: "Toggle Utility Area Without Animation", + title: "Toggle Utility Area Without Animation", + id: "open.drawer.no.animation", + command: { [weak utilityAreaViewModel] in utilityAreaViewModel?.togglePanel(animation: false) } + ) } } } diff --git a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift b/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift index cd6fdf2b3..0cc075464 100644 --- a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift +++ b/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift @@ -21,6 +21,9 @@ class UtilityAreaViewModel: ObservableObject { /// Indicates whether debugger is collapse or not @Published var isCollapsed: Bool = false + /// Indicates whether collapse animation should be enabled when utility area is toggled + @Published var animateCollapse: Bool = true + /// Returns true when the drawer is visible @Published var isMaximized: Bool = false @@ -47,7 +50,8 @@ class UtilityAreaViewModel: ObservableObject { workspace.addToWorkspaceState(key: .utilityAreaMaximized, value: isMaximized) } - func togglePanel() { + func togglePanel(animation: Bool = true) { + self.animateCollapse = animation self.isMaximized = false self.isCollapsed.toggle() } diff --git a/CodeEdit/Features/WindowCommands/ViewCommands.swift b/CodeEdit/Features/WindowCommands/ViewCommands.swift index fc6b8b72d..69854c9d5 100644 --- a/CodeEdit/Features/WindowCommands/ViewCommands.swift +++ b/CodeEdit/Features/WindowCommands/ViewCommands.swift @@ -111,6 +111,10 @@ extension ViewCommands { windowController?.toolbarCollapsed ?? true } + var isInterfaceHidden: Bool { + return windowController?.isInterfaceStillHidden() ?? false + } + var body: some View { Button("\(navigatorCollapsed ? "Show" : "Hide") Navigator") { windowController?.toggleFirstPanel() @@ -135,6 +139,12 @@ extension ViewCommands { } .disabled(windowController == nil) .keyboardShortcut("t", modifiers: [.option, .command]) + + Button("\(isInterfaceHidden ? "Show" : "Hide") Interface") { + windowController?.toggleInterface(shouldHide: !isInterfaceHidden) + } + .disabled(windowController == nil) + .keyboardShortcut(".", modifiers: .command) } } } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 69b957bcd..d9e2aa1b0 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -68,6 +68,7 @@ struct WorkspaceView: View { Rectangle() .collapsable() .collapsed($utilityAreaViewModel.isCollapsed) + .splitViewCanAnimate($utilityAreaViewModel.animateCollapse) .opacity(0) .frame(idealHeight: 260) .frame(minHeight: 100) diff --git a/CodeEditUITests/Other Tests/HideInterfaceTests.swift b/CodeEditUITests/Other Tests/HideInterfaceTests.swift new file mode 100644 index 000000000..bf804747c --- /dev/null +++ b/CodeEditUITests/Other Tests/HideInterfaceTests.swift @@ -0,0 +1,184 @@ +// +// HiderInterfaceTests.swift +// CodeEditUITests +// +// Created by Simon Kudsk on 14/05/2025. +// + +import XCTest +final class HideInterfaceUITests: XCTestCase { + + // MARK: – Setup + private var app: XCUIApplication! + private var path: String! + + override func setUp() async throws { + try await MainActor.run { + (app, path) = try App.launchWithTempDir() + } + } + + /// List of the panels to test with + private let allPanels: () -> [String] = { + ["Navigator", "Inspector", "Utility Area", "Toolbar"] + } + + // MARK: – Tests + + /// Test 1: Ensure each panel can show and hide individually. + func testPanelsShowAndHideIndividually() { + let viewMenu = app.menuBars.menuBarItems["View"] + for panel in allPanels() { + // Show panel + let showItem = "Show \(panel)" + if viewMenu.menuItems[showItem].exists { + viewMenu.menuItems[showItem].click() + } + + // Verify panel is visible + viewMenu.click() + XCTAssertTrue(viewMenu.menuItems["Hide \(panel)"].exists, "\(panel) should be visible after show") + + // Hide panel and verify it being hidden + viewMenu.menuItems[("Hide \(panel)")].click() + viewMenu.click() + XCTAssertTrue(viewMenu.menuItems["Show \(panel)"].exists, "\(panel) should be hidden after hide") + } + } + + /// Test 2: Hide interface hides all panels. + func testHideInterfaceHidesAllPanels() { + let viewMenu = app.menuBars.menuBarItems["View"] + // Ensure all panels are shown + for panel in allPanels() { + let showItem = "Show \(panel)" + if viewMenu.menuItems[showItem].exists { + viewMenu.menuItems[showItem].click() + } + } + + // Hide interface + viewMenu.menuItems[("Hide Interface")].click() + + // Verify all panels are hidden + viewMenu.click() + for panel in allPanels() { + XCTAssertTrue(viewMenu.menuItems["Show \(panel)"].exists, "\(panel) should be hidden") + } + } + + /// Test 3: Show interface shows all panels when none are visible. + func testShowInterfaceShowsAllWhenNoneVisible() { + let viewMenu = app.menuBars.menuBarItems["View"] + // Ensure all panels are hidden + for panel in allPanels() { + let hideItem = "Hide \(panel)" + if viewMenu.menuItems[hideItem].exists { + viewMenu.menuItems[hideItem].click() + } + } + + // Verify button says Show Interface + viewMenu.click() + XCTAssertTrue(viewMenu.menuItems["Show Interface"].exists, "Interface button should say Show Interface") + + // Show interface without waiting + viewMenu.menuItems[("Show Interface")].click() + + // Verify all panels are shown + viewMenu.click() + for panel in allPanels() { + XCTAssertTrue( + viewMenu.menuItems["Hide \(panel)"].exists, + "\(panel) should be visible after showing interface" + ) + } + } + + /// Test 4: Show interface restores previous panel state. + func testShowInterfaceRestoresPreviousState() { + let viewMenu = app.menuBars.menuBarItems["View"] + let initialOpen = ["Navigator", "Toolbar"] + + // Set initial state + for panel in allPanels() { + let item = initialOpen.contains(panel) ? "Show \(panel)" : "Hide \(panel)" + if viewMenu.menuItems[item].exists { + viewMenu.menuItems[item].click() + } + } + + // Hide then show interface + viewMenu.menuItems[("Hide Interface")].click() + viewMenu.menuItems[("Show Interface")].click() + + // Verify only initial panels are shown + viewMenu.click() + for panel in allPanels() { + let shouldBeVisible = initialOpen.contains(panel) + XCTAssertEqual(viewMenu.menuItems["Hide \(panel)"].exists, shouldBeVisible, "\(panel) visibility mismatch") + } + } + + /// Test 5: Individual toggles after hide update the interface button. + func testIndividualTogglesUpdateInterfaceButton() { + let viewMenu = app.menuBars.menuBarItems["View"] + let initialOpen = ["Navigator", "Toolbar"] + + // Set initial visibility + for panel in allPanels() { + let item = initialOpen.contains(panel) ? "Show \(panel)" : "Hide \(panel)" + if viewMenu.menuItems[item].exists { + viewMenu.menuItems[item].click() + } + } + + // Hide interface + viewMenu.menuItems[("Hide Interface")].click() + + // Individually enable initial panels + for panel in initialOpen { + viewMenu.menuItems[("Show \(panel)")].click() + } + + // Verify interface button resets to Hide Interface + viewMenu.click() + XCTAssertTrue( + viewMenu.menuItems["Hide Interface"].exists, + "Interface should say hide interface when all previous panels are enabled again" + ) + } + + /// Test 6: Partial show after hide restores correct panels. + func testPartialShowAfterHideRestoresCorrectPanels() { + let viewMenu = app.menuBars.menuBarItems["View"] + let initialOpen = ["Navigator", "Toolbar"] + + // Set initial visibility + for panel in allPanels() { + let item = initialOpen.contains(panel) ? "Show \(panel)" : "Hide \(panel)" + if viewMenu.menuItems[item].exists { + viewMenu.menuItems[item].click() + } + } + + // Hide interface + viewMenu.menuItems[("Hide Interface")].click() + + // Individually enable navigator and inspector + for panel in ["Navigator", "Inspector"] { + viewMenu.menuItems[("Show \(panel)")].click() + } + // Show interface + viewMenu.menuItems[("Show Interface")].click() + + // Verify correct panels are shown + viewMenu.click() + for panel in ["Navigator", "Inspector", "Toolbar"] { + XCTAssertTrue(viewMenu.menuItems["Hide \(panel)"].exists, "\(panel) should be visible") + } + + // Utility Area should remain hidden + XCTAssertTrue(viewMenu.menuItems["Show Utility Area"].exists, "Utility Area should be hidden") + } +}