Skip to content

Hide interface #2044

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion CodeEdit/Features/SplitView/Model/SplitViewItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
8 changes: 8 additions & 0 deletions CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool>) -> some View {
self
Expand All @@ -43,4 +47,8 @@ extension View {
self
._trait(SplitViewHoldingPriorityTraitKey.self, priority)
}

func splitViewCanAnimate(_ enabled: Binding<Bool>) -> some View {
self._trait(SplitViewItemCanAnimateViewTraitKey.self, enabled.wrappedValue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) }
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
Expand Down
10 changes: 10 additions & 0 deletions CodeEdit/Features/WindowCommands/ViewCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
}
}
Expand Down
1 change: 1 addition & 0 deletions CodeEdit/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ struct WorkspaceView: View {
Rectangle()
.collapsable()
.collapsed($utilityAreaViewModel.isCollapsed)
.splitViewCanAnimate($utilityAreaViewModel.animateCollapse)
.opacity(0)
.frame(idealHeight: 260)
.frame(minHeight: 100)
Expand Down
Loading
Loading