Skip to content

Commit c0eac3b

Browse files
Hide interface (#2044)
* Added initial hide interface button - Hide interface and shortcut will store the interface state and hide it - Show interface will restore the interface state * Hiding interface now remembers better - When hiding interface it will better be able to handle hidden elements being enabled, and then showing the interface again. - Moved some logic from ViewCommands to CodeEditWindowController, as it seems like a more appropriate place. - Added a bool shouldAnimate to function toggleFirstPanel. This is by default on, and thus using the default animation. When explicitly called with off, the function will not animate. * interfaceHidden will now update itself on changes - Removed need for interfaceHidden bool - Removed need for resetting stored interface state - Added function isInterfaceStillHidden, checking whether "at least" the previous elements are visible again, taking other elements into account - Hidden animation for lastpanel * Updated logic to when no panels are active - If no panels are active, and interface hasn't been hidden, all panels will show when toggle interface is clicked - Renamed objc functions with prefix 'objc' - Re-added resetStoredInterfaceCollapseState - turns out it was necessary * Disabled animation for utility area when hiding interface - Added command "open.drawer.no.animation" to command manager. This will toggle the utility area, without an animation. - Added option possibility of no animation to SplitViewItem.Update - Added struct SplitViewItemCanAnimateViewTraitKey and function splitViewCanAnimate to SplitViewModifiers. These optionally allow disabling animations for SplitViews. - Updated "Hide Interface", WorkspaceView and UtilityAreaViewModel to accommodate these changes * Rewrote and moved hide interface logic - The logic for hide interface has been moved to a new file, CodeEditWindowsController+Panels. - The function for toggling first panel and last panel has also been moved to said file. - The logic for hide interface is now much more simplified, dynamic and easier to maintain. * Asynchronous reset of stored state - In isInterfaceStillHidden(), resetStoredInterfaceCollapseState() will now be called asynchronously to avoid a SwiftUI update warning * Removed comma - Removed a comma that was causing the "Testing CodeEdit" task to fail * Added UI Tests for HideInterfaceTest behaviour * Add Toolbar Visible State to Window Restoration * Fix Failing Test after Removing Command --------- Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
1 parent 445f338 commit c0eac3b

File tree

11 files changed

+410
-28
lines changed

11 files changed

+410
-28
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//
2+
// CodeEditWindowController+Panels.swift
3+
// CodeEdit
4+
//
5+
// Created by Simon Kudsk on 11/05/2025.
6+
//
7+
8+
import SwiftUI
9+
10+
extension CodeEditWindowController {
11+
@objc
12+
func objcToggleFirstPanel() {
13+
toggleFirstPanel(shouldAnimate: true)
14+
}
15+
16+
/// Toggles the navigator pane, optionally without animation.
17+
func toggleFirstPanel(shouldAnimate: Bool = true) {
18+
guard let firstSplitView = splitViewController?.splitViewItems.first else { return }
19+
20+
if shouldAnimate {
21+
// Standard animated toggle
22+
firstSplitView.animator().isCollapsed.toggle()
23+
} else {
24+
// Instant toggle (no animation)
25+
firstSplitView.isCollapsed.toggle()
26+
}
27+
28+
splitViewController?.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed)
29+
}
30+
31+
@objc
32+
func objcToggleLastPanel() {
33+
toggleLastPanel(shouldAnimate: true)
34+
}
35+
36+
func toggleLastPanel(shouldAnimate: Bool = true) {
37+
guard let lastSplitView = splitViewController?.splitViewItems.last else {
38+
return
39+
}
40+
41+
if shouldAnimate {
42+
// Standard animated toggle
43+
NSAnimationContext.runAnimationGroup { _ in
44+
lastSplitView.animator().isCollapsed.toggle()
45+
}
46+
} else {
47+
// Instant toggle (no animation)
48+
lastSplitView.isCollapsed.toggle()
49+
}
50+
51+
splitViewController?.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed)
52+
}
53+
54+
// PanelDescriptor, used for an array of panels, for use with "Hide interface".
55+
private struct PanelDescriptor {
56+
/// Returns the current `isCollapsed` value for the panel.
57+
let isCollapsed: () -> Bool
58+
/// Returns the last stored previous state (or `nil` if none).
59+
let getPrevCollapsed: () -> Bool?
60+
/// Stores a new previous state (`nil` to clear).
61+
let setPrevCollapsed: (Bool?) -> Void
62+
/// Performs the actual toggle action for the panel.
63+
let toggle: () -> Void
64+
}
65+
66+
// The panels which "Hide interface" should interact with.
67+
private var panels: [PanelDescriptor] {
68+
[
69+
PanelDescriptor(
70+
isCollapsed: { self.navigatorCollapsed },
71+
getPrevCollapsed: { self.prevNavigatorCollapsed },
72+
setPrevCollapsed: { self.prevNavigatorCollapsed = $0 },
73+
toggle: { self.toggleFirstPanel(shouldAnimate: false) }
74+
),
75+
PanelDescriptor(
76+
isCollapsed: { self.inspectorCollapsed },
77+
getPrevCollapsed: { self.prevInspectorCollapsed },
78+
setPrevCollapsed: { self.prevInspectorCollapsed = $0 },
79+
toggle: { self.toggleLastPanel(shouldAnimate: false) }
80+
),
81+
PanelDescriptor(
82+
isCollapsed: { self.workspace?.utilityAreaModel?.isCollapsed ?? true },
83+
getPrevCollapsed: { self.prevUtilityAreaCollapsed },
84+
setPrevCollapsed: { self.prevUtilityAreaCollapsed = $0 },
85+
toggle: { CommandManager.shared.executeCommand("open.drawer") }
86+
),
87+
PanelDescriptor(
88+
isCollapsed: { self.toolbarCollapsed },
89+
getPrevCollapsed: { self.prevToolbarCollapsed },
90+
setPrevCollapsed: { self.prevToolbarCollapsed = $0 },
91+
toggle: { self.toggleToolbar() }
92+
)
93+
]
94+
}
95+
96+
/// Returns `true` if at least one panel that was visible is still collapsed, meaning the interface is still hidden
97+
func isInterfaceStillHidden() -> Bool {
98+
// Some panels do not yet have a remembered state
99+
if panels.contains(where: { $0.getPrevCollapsed() == nil }) {
100+
// Hidden only if all panels are collapsed
101+
return panels.allSatisfy { $0.isCollapsed() }
102+
}
103+
104+
// All panels have a remembered state. Check if any that were visible are still collapsed
105+
let stillHidden = panels.contains { descriptor in
106+
guard let prev = descriptor.getPrevCollapsed() else { return false }
107+
return !prev && descriptor.isCollapsed()
108+
}
109+
110+
// If the interface has been restored, reset the remembered states
111+
if !stillHidden {
112+
DispatchQueue.main.async { [weak self] in
113+
self?.resetStoredInterfaceCollapseState()
114+
}
115+
}
116+
117+
return stillHidden
118+
}
119+
120+
/// Function for toggling the interface elements on or off
121+
///
122+
/// - Parameter shouldHide: Pass `true` to hide all interface panels (and remember their current states),
123+
/// or `false` to restore them to how they were before hiding.
124+
func toggleInterface(shouldHide: Bool) {
125+
// Store the current layout before hiding
126+
if shouldHide {
127+
storeInterfaceCollapseState()
128+
}
129+
130+
// Iterate over all panels and update their state as needed
131+
for panel in panels {
132+
let targetState = determineDesiredCollapseState(
133+
shouldHide: shouldHide,
134+
currentlyCollapsed: panel.isCollapsed(),
135+
previouslyCollapsed: panel.getPrevCollapsed()
136+
)
137+
if panel.isCollapsed() != targetState {
138+
panel.toggle()
139+
}
140+
}
141+
}
142+
143+
/// Calculates the collapse state an interface element should have after a hide / show toggle.
144+
/// - Parameters:
145+
/// - shouldHide: `true` when we’re hiding the whole interface.
146+
/// - currentlyCollapsed: The panels current state
147+
/// - previouslyCollapsed: The state we saved the last time we hid the UI, if any.
148+
/// - Returns: `true` for visible element, `false` for collapsed element
149+
func determineDesiredCollapseState(shouldHide: Bool, currentlyCollapsed: Bool, previouslyCollapsed: Bool?) -> Bool {
150+
// If ShouldHide, everything should close
151+
if shouldHide {
152+
return true
153+
}
154+
155+
// If not hiding, and not currently collapsed, the panel should remain as such.
156+
if !currentlyCollapsed {
157+
return false
158+
}
159+
160+
// If the panel is currently collapsed and we are "showing" or "restoring":
161+
// Option 1: Restore to its previously remembered state if available.
162+
// Option 2: If no previously remembered state, default to making it visible (not collapsed).
163+
return previouslyCollapsed ?? false
164+
}
165+
166+
/// Function for storing the current interface visibility states
167+
func storeInterfaceCollapseState() {
168+
for panel in panels {
169+
panel.setPrevCollapsed(panel.isCollapsed())
170+
}
171+
}
172+
173+
/// Function for resetting the stored interface visibility states
174+
func resetStoredInterfaceCollapseState() {
175+
for panel in panels {
176+
panel.setPrevCollapsed(nil)
177+
}
178+
}
179+
}

CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ extension CodeEditWindowController {
5656

5757
func toggleToolbar() {
5858
toolbarCollapsed.toggle()
59+
workspace?.addToWorkspaceState(key: .toolbarCollapsed, value: toolbarCollapsed)
5960
updateToolbarVisibility()
6061
}
6162

62-
private func updateToolbarVisibility() {
63+
func updateToolbarVisibility() {
6364
if toolbarCollapsed {
6465
window?.titleVisibility = .visible
6566
window?.title = workspace?.workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty"
@@ -92,7 +93,7 @@ extension CodeEditWindowController {
9293
toolbarItem.toolTip = "Hide or show the Navigator"
9394
toolbarItem.isBordered = true
9495
toolbarItem.target = self
95-
toolbarItem.action = #selector(self.toggleFirstPanel)
96+
toolbarItem.action = #selector(self.objcToggleFirstPanel)
9697
toolbarItem.image = NSImage(
9798
systemSymbolName: "sidebar.leading",
9899
accessibilityDescription: nil
@@ -106,7 +107,7 @@ extension CodeEditWindowController {
106107
toolbarItem.toolTip = "Hide or show the Inspectors"
107108
toolbarItem.isBordered = true
108109
toolbarItem.target = self
109-
toolbarItem.action = #selector(self.toggleLastPanel)
110+
toolbarItem.action = #selector(self.objcToggleLastPanel)
110111
toolbarItem.image = NSImage(
111112
systemSymbolName: "sidebar.trailing",
112113
accessibilityDescription: nil

CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@ import SwiftUI
1010
import Combine
1111

1212
final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, ObservableObject, NSWindowDelegate {
13-
@Published var navigatorCollapsed = false
14-
@Published var inspectorCollapsed = false
15-
@Published var toolbarCollapsed = false
13+
@Published var navigatorCollapsed: Bool = false
14+
@Published var inspectorCollapsed: Bool = false
15+
@Published var toolbarCollapsed: Bool = false
16+
17+
// These variables store the state of the windows when using "Hide interface"
18+
@Published var prevNavigatorCollapsed: Bool?
19+
@Published var prevInspectorCollapsed: Bool?
20+
@Published var prevUtilityAreaCollapsed: Bool?
21+
@Published var prevToolbarCollapsed: Bool?
1622

1723
private var panelOpen = false
1824

@@ -38,6 +44,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs
3844
window?.delegate = self
3945
guard let workspace else { return }
4046
self.workspace = workspace
47+
self.toolbarCollapsed = workspace.getFromWorkspaceState(.toolbarCollapsed) as? Bool ?? false
4148
guard let splitViewController = setupSplitView(with: workspace) else {
4249
fatalError("Failed to set up content view.")
4350
}
@@ -67,6 +74,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs
6774
]
6875

6976
setupToolbar()
77+
updateToolbarVisibility()
7078
registerCommands()
7179
}
7280

CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,6 @@ import SwiftUI
99
import Combine
1010

1111
extension CodeEditWindowController {
12-
@objc
13-
func toggleFirstPanel() {
14-
guard let firstSplitView = splitViewController?.splitViewItems.first else { return }
15-
firstSplitView.animator().isCollapsed.toggle()
16-
splitViewController?.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed)
17-
}
18-
19-
@objc
20-
func toggleLastPanel() {
21-
guard let lastSplitView = splitViewController?.splitViewItems.last else {
22-
return
23-
}
24-
25-
NSAnimationContext.runAnimationGroup { _ in
26-
lastSplitView.animator().isCollapsed.toggle()
27-
}
28-
29-
splitViewController?.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed)
30-
}
31-
3212
/// These are example items that added as commands to command palette
3313
func registerCommands() {
3414
CommandManager.shared.addCommand(

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ enum WorkspaceStateKey: String {
1414
case splitViewWidth
1515
case navigatorCollapsed
1616
case inspectorCollapsed
17+
case toolbarCollapsed
1718
}

CodeEdit/Features/SplitView/Model/SplitViewItem.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,15 @@ class SplitViewItem: ObservableObject {
4545
/// - Parameter child: the view corresponding to the SplitViewItem.
4646
func update(child: _VariadicView.Children.Element) {
4747
self.item.canCollapse = child[SplitViewItemCanCollapseViewTraitKey.self]
48+
let canAnimate = child[SplitViewItemCanAnimateViewTraitKey.self]
4849
DispatchQueue.main.async {
4950
self.observers = []
50-
self.item.animator().isCollapsed = child[SplitViewItemCollapsedViewTraitKey.self].wrappedValue
51+
let collapsed = child[SplitViewItemCollapsedViewTraitKey.self].wrappedValue
52+
if canAnimate {
53+
self.item.animator().isCollapsed = collapsed
54+
} else {
55+
self.item.isCollapsed = collapsed
56+
}
5157
self.item.holdingPriority = child[SplitViewHoldingPriorityTraitKey.self]
5258
self.observers = self.createObservers()
5359
}

CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ struct SplitViewHoldingPriorityTraitKey: _ViewTraitKey {
2323
static var defaultValue: NSLayoutConstraint.Priority = .defaultLow
2424
}
2525

26+
struct SplitViewItemCanAnimateViewTraitKey: _ViewTraitKey {
27+
static var defaultValue: Bool { true }
28+
}
29+
2630
extension View {
2731
func collapsed(_ value: Binding<Bool>) -> some View {
2832
self
@@ -43,4 +47,8 @@ extension View {
4347
self
4448
._trait(SplitViewHoldingPriorityTraitKey.self, priority)
4549
}
50+
51+
func splitViewCanAnimate(_ enabled: Binding<Bool>) -> some View {
52+
self._trait(SplitViewItemCanAnimateViewTraitKey.self, enabled.wrappedValue)
53+
}
4654
}

CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class UtilityAreaViewModel: ObservableObject {
2121
/// Indicates whether debugger is collapse or not
2222
@Published var isCollapsed: Bool = false
2323

24+
/// Indicates whether collapse animation should be enabled when utility area is toggled
25+
@Published var animateCollapse: Bool = true
26+
2427
/// Returns true when the drawer is visible
2528
@Published var isMaximized: Bool = false
2629

@@ -47,7 +50,8 @@ class UtilityAreaViewModel: ObservableObject {
4750
workspace.addToWorkspaceState(key: .utilityAreaMaximized, value: isMaximized)
4851
}
4952

50-
func togglePanel() {
53+
func togglePanel(animation: Bool = true) {
54+
self.animateCollapse = animation
5155
self.isMaximized = false
5256
self.isCollapsed.toggle()
5357
}

CodeEdit/Features/WindowCommands/ViewCommands.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ extension ViewCommands {
111111
windowController?.toolbarCollapsed ?? true
112112
}
113113

114+
var isInterfaceHidden: Bool {
115+
return windowController?.isInterfaceStillHidden() ?? false
116+
}
117+
114118
var body: some View {
115119
Button("\(navigatorCollapsed ? "Show" : "Hide") Navigator") {
116120
windowController?.toggleFirstPanel()
@@ -135,6 +139,12 @@ extension ViewCommands {
135139
}
136140
.disabled(windowController == nil)
137141
.keyboardShortcut("t", modifiers: [.option, .command])
142+
143+
Button("\(isInterfaceHidden ? "Show" : "Hide") Interface") {
144+
windowController?.toggleInterface(shouldHide: !isInterfaceHidden)
145+
}
146+
.disabled(windowController == nil)
147+
.keyboardShortcut(".", modifiers: .command)
138148
}
139149
}
140150
}

CodeEdit/WorkspaceView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ struct WorkspaceView: View {
6868
Rectangle()
6969
.collapsable()
7070
.collapsed($utilityAreaViewModel.isCollapsed)
71+
.splitViewCanAnimate($utilityAreaViewModel.animateCollapse)
7172
.opacity(0)
7273
.frame(idealHeight: 260)
7374
.frame(minHeight: 100)

0 commit comments

Comments
 (0)