diff --git a/Muxy/Views/MainWindow.swift b/Muxy/Views/MainWindow.swift index b316db2e..0ba4ba20 100644 --- a/Muxy/Views/MainWindow.swift +++ b/Muxy/Views/MainWindow.swift @@ -1,6 +1,37 @@ import AppKit import SwiftUI +enum MainWindowLayout { + static func leftNavigationWidth( + sidebarWidth: CGFloat, + titleBarNavigationWidth: CGFloat, + isFullScreen: Bool + ) -> CGFloat { + guard sidebarWidth > 0 else { return 0 } + _ = titleBarNavigationWidth + _ = isFullScreen + return sidebarWidth + } + + static func titleBarNavigationOverlayWidth( + leftNavigationWidth: CGFloat, + titleBarNavigationWidth: CGFloat, + isFullScreen: Bool + ) -> CGFloat { + guard !isFullScreen else { return 0 } + return max(leftNavigationWidth, titleBarNavigationWidth) + } + + static func mainTitleBarLeadingInset( + leftNavigationWidth: CGFloat, + titleBarNavigationOverlayWidth: CGFloat, + isFullScreen: Bool + ) -> CGFloat { + guard !isFullScreen else { return 0 } + return max(0, titleBarNavigationOverlayWidth - leftNavigationWidth) + } +} + struct MainWindow: View { @Environment(AppState.self) private var appState @Environment(ProjectStore.self) private var projectStore @@ -89,89 +120,13 @@ struct MainWindow: View { @MainActor private var trafficLightWidth: CGFloat { UIMetrics.scaled(75) } var body: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - if !isFullScreen { - Color.clear - .frame(width: topBarLeadingWidth) - .fixedSize(horizontal: true, vertical: false) - .overlay(alignment: .trailing) { - HStack(spacing: 0) { - navigationArrows - Rectangle().fill(MuxyTheme.border).frame(width: 1) - } - } - } - topBarContent - } - .frame(height: UIMetrics.scaled(32)) - .background(WindowDragRepresentable()) - .background(MuxyTheme.bg) - - Rectangle().fill(MuxyTheme.border).frame(height: 1) - .background(MuxyTheme.bg) - - HStack(spacing: 0) { - HStack(spacing: 0) { - Sidebar() - if !SidebarLayout.isHidden(expanded: sidebarExpanded, collapsedStyle: sidebarCollapsedStyle) { - Rectangle().fill(MuxyTheme.border).frame(width: 1) - .accessibilityHidden(true) - } - } - .fixedSize(horizontal: true, vertical: false) - .background(MuxyTheme.bg) - - VStack(spacing: 0) { - HStack(spacing: 0) { - ZStack { - MuxyTheme.bg - if let project = activeProject, - appState.workspaceRoot(for: project.id) == nil, - let worktree = resolvedActiveWorktree(for: project) - { - EmptyProjectPlaceholder(project: project) { - appState.selectWorktree(projectID: project.id, worktree: worktree) - } - } else if projectsWithWorkspaces.isEmpty { - WelcomeView() - } else if let project = activeProjectWithWorkspace, - let activeKey = appState.activeWorktreeKey(for: project.id) - { - ForEach(mountedWorktreeKeys(for: project), id: \.self) { key in - TerminalArea( - project: project, - worktreeKey: key, - isActiveProject: key == activeKey - ) - .opacity(key == activeKey ? 1 : 0) - .allowsHitTesting(key == activeKey) - .zIndex(key == activeKey ? 1 : 0) - } - } - } - - rightSidePanel - } - .overlay(alignment: .trailing) { - floatingRichInputOverlay - } - .overlay(alignment: .bottom) { - floatingBottomRichInputOverlay - } - .animation(.easeInOut(duration: 0.2), value: richInputPanelVisible) - - bottomDockedRichInputPanel - - ProjectStatusBar( - activePane: activeTerminalPane, - activeWorktree: activeProject.flatMap { resolvedActiveWorktree(for: $0) }, - isInteractive: activeTerminalPane != nil && !overlayAnimatingOut, - richInputVisible: richInputPanelVisible, - richInputFontSize: $richInputFontSize - ) - } - } + HStack(spacing: 0) { + leftNavigationColumn + mainWorkspaceColumn + } + .animation(.easeInOut(duration: 0.2), value: sidebarExpanded) + .overlay(alignment: .topLeading) { + titleBarNavigationOverlay } .environment(\.overlayActive, showQuickOpen || showFindInFiles || showWorktreeSwitcher || overlayAnimatingOut) .overlay(alignment: toastAlignment) { @@ -275,6 +230,7 @@ struct MainWindow: View { withAnimation(.easeInOut(duration: 0.2)) { sidebarExpanded.toggle() } + UserDefaults.standard.set(sidebarExpanded, forKey: "muxy.sidebarExpanded") } .onReceive(NotificationCenter.default.publisher(for: .windowFullScreenDidChange)) { notification in isFullScreen = notification.userInfo?["isFullScreen"] as? Bool ?? false @@ -322,6 +278,137 @@ struct MainWindow: View { .modifier(SentryConsentPrompter()) } + private var leftNavigationColumn: some View { + VStack(spacing: 0) { + if !isFullScreen { + Color.clear + .frame(height: UIMetrics.scaled(32)) + .background(WindowDragRepresentable()) + + Rectangle().fill(MuxyTheme.border).frame(height: 1) + .accessibilityHidden(true) + } + + Sidebar(expanded: sidebarExpanded) + } + .frame(width: leftNavigationWidth, alignment: .leading) + .clipped() + .background(MuxyTheme.bg) + .overlay(alignment: .trailing) { + if leftNavigationWidth > 0 { + Rectangle().fill(MuxyTheme.border) + .frame(width: 1) + .padding(.top, leftNavigationBorderTopPadding) + .accessibilityHidden(true) + } + } + .fixedSize(horizontal: true, vertical: false) + .animation(.easeInOut(duration: 0.2), value: leftNavigationWidth) + } + + private var mainWorkspaceColumn: some View { + VStack(spacing: 0) { + mainTitleBarContent + .frame(height: UIMetrics.scaled(32)) + .background(WindowDragRepresentable()) + .background(MuxyTheme.bg) + + Rectangle().fill(MuxyTheme.border).frame(height: 1) + .background(MuxyTheme.bg) + + workspaceContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var mainTitleBarContent: some View { + HStack(spacing: 0) { + if mainTitleBarLeadingInset > 0 { + Color.clear + .frame(width: mainTitleBarLeadingInset) + .fixedSize(horizontal: true, vertical: false) + } + + topBarContent + } + .animation(.easeInOut(duration: 0.2), value: mainTitleBarLeadingInset) + } + + private var titleBarNavigationOverlay: some View { + Group { + if !isFullScreen { + Color.clear + .frame(width: titleBarNavigationOverlayWidth, height: UIMetrics.scaled(32)) + .fixedSize(horizontal: true, vertical: false) + .background(WindowDragRepresentable()) + .background(MuxyTheme.bg) + .overlay(alignment: .trailing) { + HStack(spacing: 0) { + navigationArrows + if titleBarNavigationOverflowsSidebar { + Rectangle().fill(MuxyTheme.border).frame(width: 1) + .accessibilityHidden(true) + } + } + } + .zIndex(1) + } + } + .animation(.easeInOut(duration: 0.2), value: titleBarNavigationOverlayWidth) + } + + private var workspaceContent: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + ZStack { + MuxyTheme.bg + if let project = activeProject, + appState.workspaceRoot(for: project.id) == nil, + let worktree = resolvedActiveWorktree(for: project) + { + EmptyProjectPlaceholder(project: project) { + appState.selectWorktree(projectID: project.id, worktree: worktree) + } + } else if projectsWithWorkspaces.isEmpty { + WelcomeView() + } else if let project = activeProjectWithWorkspace, + let activeKey = appState.activeWorktreeKey(for: project.id) + { + ForEach(mountedWorktreeKeys(for: project), id: \.self) { key in + TerminalArea( + project: project, + worktreeKey: key, + isActiveProject: key == activeKey + ) + .opacity(key == activeKey ? 1 : 0) + .allowsHitTesting(key == activeKey) + .zIndex(key == activeKey ? 1 : 0) + } + } + } + + rightSidePanel + } + .overlay(alignment: .trailing) { + floatingRichInputOverlay + } + .overlay(alignment: .bottom) { + floatingBottomRichInputOverlay + } + .animation(.easeInOut(duration: 0.2), value: richInputPanelVisible) + + bottomDockedRichInputPanel + + ProjectStatusBar( + activePane: activeTerminalPane, + activeWorktree: activeProject.flatMap { resolvedActiveWorktree(for: $0) }, + isInteractive: activeTerminalPane != nil && !overlayAnimatingOut, + richInputVisible: richInputPanelVisible, + richInputFontSize: $richInputFontSize + ) + } + } + private var navigationArrows: some View { HStack(spacing: UIMetrics.spacing1) { NavigationArrowButton( @@ -620,14 +707,48 @@ struct MainWindow: View { SidebarExpandedStyle(rawValue: sidebarExpandedStyleRaw) ?? .defaultValue } - private var topBarLeadingWidth: CGFloat { - let sidebarWidth = SidebarLayout.resolvedWidth( + private var sidebarResolvedWidth: CGFloat { + SidebarLayout.resolvedWidth( expanded: sidebarExpanded, collapsedStyle: sidebarCollapsedStyle, expandedStyle: sidebarExpandedStyle - ) + 1 - let navigationMinimum = trafficLightWidth + navigationArrowsWidth - return max(navigationMinimum, sidebarWidth) + ) + } + + private var leftNavigationWidth: CGFloat { + MainWindowLayout.leftNavigationWidth( + sidebarWidth: sidebarResolvedWidth, + titleBarNavigationWidth: titleBarNavigationWidth, + isFullScreen: isFullScreen + ) + } + + private var titleBarNavigationOverlayWidth: CGFloat { + MainWindowLayout.titleBarNavigationOverlayWidth( + leftNavigationWidth: leftNavigationWidth, + titleBarNavigationWidth: titleBarNavigationWidth, + isFullScreen: isFullScreen + ) + } + + private var mainTitleBarLeadingInset: CGFloat { + MainWindowLayout.mainTitleBarLeadingInset( + leftNavigationWidth: leftNavigationWidth, + titleBarNavigationOverlayWidth: titleBarNavigationOverlayWidth, + isFullScreen: isFullScreen + ) + } + + private var titleBarNavigationOverflowsSidebar: Bool { + titleBarNavigationOverlayWidth > leftNavigationWidth + } + + private var leftNavigationBorderTopPadding: CGFloat { + titleBarNavigationOverflowsSidebar ? UIMetrics.scaled(33) : 0 + } + + private var titleBarNavigationWidth: CGFloat { + trafficLightWidth + navigationArrowsWidth } private var navigationArrowsWidth: CGFloat { UIMetrics.scaled(52) } diff --git a/Muxy/Views/Sidebar.swift b/Muxy/Views/Sidebar.swift index 292b340b..284f6f3b 100644 --- a/Muxy/Views/Sidebar.swift +++ b/Muxy/Views/Sidebar.swift @@ -31,7 +31,7 @@ struct Sidebar: View { @Environment(ProjectStore.self) private var projectStore @Environment(WorktreeStore.self) private var worktreeStore @State private var dragState = ProjectDragState() - @State private var expanded = UserDefaults.standard.bool(forKey: "muxy.sidebarExpanded") + let expanded: Bool @AppStorage(SidebarCollapsedStyle.storageKey) private var collapsedStyleRaw = SidebarCollapsedStyle.defaultValue.rawValue @AppStorage(SidebarExpandedStyle.storageKey) private var expandedStyleRaw = SidebarExpandedStyle.defaultValue.rawValue @@ -57,7 +57,7 @@ struct Sidebar: View { .frame(minHeight: 0, maxHeight: .infinity, alignment: .top) .clipped() - SidebarFooter(expanded: isWide) + SidebarFooter(isWide: isWide, sidebarExpanded: expanded) .fixedSize(horizontal: false, vertical: true) } .frame(maxHeight: .infinity, alignment: .bottom) @@ -65,16 +65,6 @@ struct Sidebar: View { .opacity(isHidden ? 0 : 1) .accessibilityElement(children: .contain) .accessibilityLabel("Sidebar") - .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in - toggleExpanded() - } - } - - private func toggleExpanded() { - withAnimation(.easeInOut(duration: 0.2)) { - expanded.toggle() - } - UserDefaults.standard.set(expanded, forKey: "muxy.sidebarExpanded") } private var addButton: some View { @@ -272,7 +262,8 @@ private struct AddProjectButton: View { } struct SidebarFooter: View { - var expanded: Bool = false + var isWide = false + var sidebarExpanded = false @AppStorage(AIUsageSettingsStore.usageEnabledKey) private var usageEnabled = false @AppStorage(AIUsageSettingsStore.usageDisplayModeKey) private var usageDisplayModeRaw = AIUsageSettingsStore.defaultUsageDisplayMode .rawValue @@ -292,7 +283,7 @@ struct SidebarFooter: View { var body: some View { VStack(spacing: 0) { - if expanded { + if isWide { expandedFooter } else { collapsedFooter @@ -328,7 +319,7 @@ struct SidebarFooter: View { } private var sidebarToggleLabel: String { - expanded ? "Collapse Sidebar" : "Expand Sidebar" + sidebarExpanded ? "Collapse Sidebar" : "Expand Sidebar" } private var sidebarToggleIcon: String { @@ -366,7 +357,7 @@ struct SidebarFooter: View { AIUsagePreviewButton( display: previewProviderDisplay, percentLabel: previewProviderPercentLabel, - expanded: expanded, + expanded: isWide, onTap: { showAIUsagePopover.toggle() } ) .popover(isPresented: $showAIUsagePopover) { diff --git a/Tests/MuxyTests/Views/SidebarLayoutTests.swift b/Tests/MuxyTests/Views/SidebarLayoutTests.swift new file mode 100644 index 00000000..22b7e798 --- /dev/null +++ b/Tests/MuxyTests/Views/SidebarLayoutTests.swift @@ -0,0 +1,135 @@ +import Testing +import CoreGraphics + +@testable import Muxy + +@Suite("SidebarLayout") +@MainActor +struct SidebarLayoutTests { + @Test("collapsed hidden sidebar has zero width and is hidden") + func collapsedHiddenSidebar() { + #expect(SidebarLayout.resolvedWidth( + expanded: false, + collapsedStyle: .hidden, + expandedStyle: .wide + ) == 0) + #expect(SidebarLayout.isHidden(expanded: false, collapsedStyle: .hidden)) + #expect(!SidebarLayout.isWide(expanded: false, expandedStyle: .wide)) + } + + @Test("collapsed icons sidebar uses icon rail width") + func collapsedIconsSidebar() { + #expect(SidebarLayout.resolvedWidth( + expanded: false, + collapsedStyle: .icons, + expandedStyle: .wide + ) == SidebarLayout.collapsedWidth) + #expect(!SidebarLayout.isHidden(expanded: false, collapsedStyle: .icons)) + #expect(!SidebarLayout.isWide(expanded: false, expandedStyle: .wide)) + } + + @Test("expanded icons sidebar remains an icon rail without becoming hidden") + func expandedIconsSidebar() { + #expect(SidebarLayout.resolvedWidth( + expanded: true, + collapsedStyle: .hidden, + expandedStyle: .icons + ) == SidebarLayout.collapsedWidth) + #expect(!SidebarLayout.isHidden(expanded: true, collapsedStyle: .hidden)) + #expect(!SidebarLayout.isWide(expanded: true, expandedStyle: .icons)) + } + + @Test("expanded wide sidebar uses full sidebar width") + func expandedWideSidebar() { + #expect(SidebarLayout.resolvedWidth( + expanded: true, + collapsedStyle: .hidden, + expandedStyle: .wide + ) == SidebarLayout.expandedWidth) + #expect(!SidebarLayout.isHidden(expanded: true, collapsedStyle: .hidden)) + #expect(SidebarLayout.isWide(expanded: true, expandedStyle: .wide)) + } +} + +@Suite("MainWindowLayout") +struct MainWindowLayoutTests { + @Test("collapsed icon sidebar keeps its own rail width") + func collapsedIconSidebarKeepsRailWidth() { + #expect(MainWindowLayout.leftNavigationWidth( + sidebarWidth: 44, + titleBarNavigationWidth: 127, + isFullScreen: false + ) == 44) + } + + @Test("wide sidebar owns the title bar height instead of sitting below tab strip") + func wideSidebarExtendsThroughTitleBar() { + #expect(MainWindowLayout.leftNavigationWidth( + sidebarWidth: 220, + titleBarNavigationWidth: 127, + isFullScreen: false + ) == 220) + #expect(MainWindowLayout.titleBarNavigationOverlayWidth( + leftNavigationWidth: 220, + titleBarNavigationWidth: 127, + isFullScreen: false + ) == 220) + #expect(MainWindowLayout.mainTitleBarLeadingInset( + leftNavigationWidth: 220, + titleBarNavigationOverlayWidth: 220, + isFullScreen: false + ) == 0) + } + + @Test("narrow visible sidebar reserves only overflow in main title bar") + func narrowSidebarReservesOverflowInTitleBar() { + #expect(MainWindowLayout.titleBarNavigationOverlayWidth( + leftNavigationWidth: 44, + titleBarNavigationWidth: 127, + isFullScreen: false + ) == 127) + #expect(MainWindowLayout.mainTitleBarLeadingInset( + leftNavigationWidth: 44, + titleBarNavigationOverlayWidth: 127, + isFullScreen: false + ) == 83) + } + + @Test("hidden sidebar keeps full title bar navigation inset") + func hiddenSidebarLeavesNavigationInTitleBar() { + #expect(MainWindowLayout.leftNavigationWidth( + sidebarWidth: 0, + titleBarNavigationWidth: 127, + isFullScreen: false + ) == 0) + #expect(MainWindowLayout.titleBarNavigationOverlayWidth( + leftNavigationWidth: 0, + titleBarNavigationWidth: 127, + isFullScreen: false + ) == 127) + #expect(MainWindowLayout.mainTitleBarLeadingInset( + leftNavigationWidth: 0, + titleBarNavigationOverlayWidth: 127, + isFullScreen: false + ) == 127) + } + + @Test("full screen uses sidebar width without title bar navigation overlay") + func fullScreenSidebarUsesResolvedWidth() { + #expect(MainWindowLayout.leftNavigationWidth( + sidebarWidth: 44, + titleBarNavigationWidth: 127, + isFullScreen: true + ) == 44) + #expect(MainWindowLayout.titleBarNavigationOverlayWidth( + leftNavigationWidth: 44, + titleBarNavigationWidth: 127, + isFullScreen: true + ) == 0) + #expect(MainWindowLayout.mainTitleBarLeadingInset( + leftNavigationWidth: 44, + titleBarNavigationOverlayWidth: 0, + isFullScreen: true + ) == 0) + } +}