From 405877f8414d90c6d7ee7b7353b5df37b70b5fd2 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 24 Jun 2026 18:59:58 +0800 Subject: [PATCH] MoriUI: move terminal tabs into the window titlebar (Chrome-style) Replace the separate 34pt tab strip above the terminal with a native AppKit tabs view hosted as a toolbar item in the titlebar, filling the previously-empty space between the left and right toolbar icon groups. This reclaims a full row of vertical height for the terminal. The tabs are native AppKit (NSStackView of NSControl pills), not SwiftUI: hosting SwiftUI inside an NSToolbar flexible-space layout repeatedly rendered as a blank strip. A low-priority oversized tail spacer absorbs the slack so every tab keeps a fixed 160pt width, left-aligned with empty space on the right, without NSStackView's `.fill` stretching the first tab and without triggering toolbar overflow. --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + Sources/Mori/App/AppDelegate.swift | 3 +- Sources/Mori/App/MainWindowController.swift | 86 ++++- .../Mori/App/TerminalAreaViewController.swift | 159 +------- Sources/Mori/App/TerminalTabsBarView.swift | 362 ++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + 8 files changed, 455 insertions(+), 159 deletions(-) create mode 100644 Sources/Mori/App/TerminalTabsBarView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4c823..b7bcb5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🎨 Design +- **macOS**: Moved the terminal tabs into the window titlebar (Chrome-style) instead of a separate strip above the terminal. The tabs now fill the previously-empty titlebar space next to the toolbar icons, reclaiming a full row of vertical height for the terminal. - **macOS**: Reworked the sidebar worktree row to read like a native list instead of a dense terminal node. The leading dot is now a glyph that fuses identity and agent state — a branch glyph for the main worktree, a node graph for linked worktrees, tinted by state and pulsing while an agent waits on you. Branch names switched from monospace to a proportional 13pt, and the selected row is a solid accent fill with white text instead of a faint gradient, so "you are here" reads at a glance. - **macOS**: Flattened the sidebar to two levels by default. tmux windows (the third level) are now collapsed behind a `N ›` chip on the worktree row and only expand on demand — single-window worktrees show no chip since selecting the worktree already lands on its window. The project header was demoted to a quiet group label (smaller tile, muted name, no competing selection highlight) with a status pip only when something inside needs you. When a hidden window is waiting or errored, a small dot appears on the chip instead of forcing the level open. - **macOS**: On the collapsed sidebar rail, a project whose agent is waiting on you now pulses its ring (mirroring the expanded glyph), so the dock actively draws your eye to whoever needs input. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 11f4f4c..480fdeb 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -9,6 +9,7 @@ ### 🎨 界面优化 +- **macOS**:把终端标签页移进窗口标题栏(Chrome 风格),不再单独占用终端上方的一条。标签页现在填进标题栏里工具栏图标旁那块原本空着的区域,为终端腾回一整行的纵向高度。 - **macOS**:重做侧栏 worktree 行,让它像原生列表而非密集的终端节点。左侧小圆点改为承载身份与 agent 状态的 glyph——主 worktree 用分支图标、linked worktree 用节点图,按状态着色,agent 等待输入时脉冲闪动。分支名从等宽改为比例 13pt,选中行从淡渐变改为实心强调色 + 白字,「当前位置」一眼可辨。 - **macOS**:侧栏默认收为两级。tmux 窗口(第三级)现在收进 worktree 行尾的 `N ›` chip,点击才展开——单窗口的 worktree 不显示 chip,因为选中该 worktree 本就直达其窗口。项目头降级为安静的分组标签(更小的字母块、灰化名称、去掉抢眼的选中高亮),仅当其内有事需要你处理时才显示状态圆点。被收起的窗口处于等待/报错时,chip 上会出现一个小圆点,而非强行撑开该层级。 - **macOS**:收起态侧栏(rail)中,有 agent 等待你输入的项目,其外环现在会呼吸闪动(与展开态的 glyph 一致),让 dock 主动把视线引向需要处理的项目。 diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index a43ed70..1f2ce07 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -265,7 +265,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.sidebarController = sidebarController sidebarController.updateAppearance(themeInfo: themeInfo) - terminalArea.configureTabs( + let tabsView = TerminalTabsBarView( appState: state, onSelectWindow: { [weak manager, weak self] windowId in manager?.selectWindow(windowId) @@ -285,6 +285,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } ) + windowController.installTabsView(tabsView) let splitVC = RootSplitViewController( sidebarController: sidebarController, diff --git a/Sources/Mori/App/MainWindowController.swift b/Sources/Mori/App/MainWindowController.swift index 82b0486..2447d04 100644 --- a/Sources/Mori/App/MainWindowController.swift +++ b/Sources/Mori/App/MainWindowController.swift @@ -20,6 +20,8 @@ final class MainWindowController: NSWindowController { static let git = NSToolbarItem.Identifier("openGit") static let splitRight = NSToolbarItem.Identifier("splitRight") static let splitDown = NSToolbarItem.Identifier("splitDown") + static let tabsLeadingSpacer = NSToolbarItem.Identifier("terminalTabsLeadingSpacer") + static let tabs = NSToolbarItem.Identifier("terminalTabs") } private struct ToolbarItemDef { @@ -75,6 +77,13 @@ final class MainWindowController: NSWindowController { /// The hosting view for the update pill, overlaid on the titlebar. private var updateOverlay: NSView? + /// Native AppKit terminal tabs strip, shown as a toolbar item in the titlebar. + /// Created by `installTabsView(_:)` once AppState/callbacks are available; the + /// toolbar is built afterwards so the item has a real size. + private var tabsView: TerminalTabsBarView? + private var tabsLeftMargin: CGFloat? + private var rightToolbarTrailingInset: CGFloat? + // MARK: - Shortcut Hints private let shortcutHintMonitor = ShortcutHintModifierMonitor() @@ -102,7 +111,6 @@ final class MainWindowController: NSWindowController { super.init(window: window) window.delegate = self - configureToolbar() startShortcutHintMonitor() } @@ -132,6 +140,16 @@ final class MainWindowController: NSWindowController { onShowCreateWorktreePanel?() } + /// Install the terminal tabs strip into the titlebar and build the toolbar. + /// The tabs view reports an intrinsic content size that tracks the tab count, + /// so the toolbar item grows and shrinks with the tabs. The toolbar is + /// configured here (not in init) so the item already has a real size when + /// AppKit first measures it — otherwise it gets shoved into the overflow menu. + func installTabsView(_ rootView: TerminalTabsBarView) { + tabsView = rootView + configureToolbar() + } + func addUpdateAccessory(viewModel: UpdateViewModel) { guard let window else { return } guard let themeFrame = window.contentView?.superview else { return } @@ -165,6 +183,43 @@ final class MainWindowController: NSWindowController { toolbar.showsBaselineSeparator = false window?.toolbar = toolbar window?.toolbarStyle = .unifiedCompact + DispatchQueue.main.async { [weak self] in + self?.updateTabsStripWidth() + } + } + + private func updateTabsStripWidth() { + guard let window, + let tabsView, + let leftAnchor = visibleToolbarView(for: ToolbarID.agentDashboard), + let rightAnchor = visibleToolbarView(for: ToolbarID.files) else { return } + window.contentView?.superview?.layoutSubtreeIfNeeded() + + let leftMax = leftAnchor.convert(leftAnchor.bounds, to: nil).maxX + let tabsFrame = tabsView.convert(tabsView.bounds, to: nil) + guard tabsFrame.width > 0 else { return } + + let measuredLeftMargin = max(0, tabsFrame.minX - leftMax) + if tabsLeftMargin == nil || measuredLeftMargin > 0 { + tabsLeftMargin = measuredLeftMargin + } + + if rightAnchor.window === window { + let rightMin = rightAnchor.convert(rightAnchor.bounds, to: nil).minX + if rightMin > leftMax, rightMin < window.frame.width { + rightToolbarTrailingInset = window.frame.width - rightMin + } + } + + guard let tabsLeftMargin, let rightToolbarTrailingInset else { return } + let targetRightMin = window.frame.width - rightToolbarTrailingInset + let targetWidth = targetRightMin - tabsFrame.minX - tabsLeftMargin + tabsView.setStripWidth(targetWidth) + } + + private func visibleToolbarView(for id: NSToolbarItem.Identifier) -> NSView? { + let visibleItems = window?.toolbar?.visibleItems ?? [] + return visibleItems.first(where: { $0.itemIdentifier == id })?.view } private static let symbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) @@ -282,6 +337,10 @@ extension MainWindowController: NSWindowDelegate { func windowDidResignKey(_ notification: Notification) { onWindowAppearanceInvalidated?() } + + func windowDidResize(_ notification: Notification) { + updateTabsStripWidth() + } } // MARK: - NSToolbarDelegate @@ -293,6 +352,8 @@ extension MainWindowController: NSToolbarDelegate { ToolbarID.openProject, ToolbarID.commandPalette, ToolbarID.agentDashboard, + ToolbarID.tabsLeadingSpacer, + ToolbarID.tabs, .flexibleSpace, ToolbarID.files, ToolbarID.git, @@ -314,6 +375,29 @@ extension MainWindowController: NSToolbarDelegate { itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool ) -> NSToolbarItem? { + if itemIdentifier == ToolbarID.tabsLeadingSpacer { + let item = NSToolbarItem(itemIdentifier: ToolbarID.tabsLeadingSpacer) + let spacer = NSView(frame: NSRect(x: 0, y: 0, width: 14, height: 24)) + spacer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + spacer.widthAnchor.constraint(equalToConstant: 14), + spacer.heightAnchor.constraint(equalToConstant: 24), + ]) + item.view = spacer + return item + } + if itemIdentifier == ToolbarID.tabs { + let item = NSToolbarItem(itemIdentifier: ToolbarID.tabs) + item.label = .localized("Tabs") + item.paletteLabel = .localized("Tabs") + item.visibilityPriority = .high + item.view = tabsView + tabsView?.onIntrinsicContentSizeChanged = { [weak self, weak toolbar] in + toolbar?.validateVisibleItems() + DispatchQueue.main.async { self?.updateTabsStripWidth() } + } + return item + } guard let def = Self.toolbarItemDefs.first(where: { $0.id == itemIdentifier }) else { return nil } diff --git a/Sources/Mori/App/TerminalAreaViewController.swift b/Sources/Mori/App/TerminalAreaViewController.swift index 0923e8f..a0f7918 100644 --- a/Sources/Mori/App/TerminalAreaViewController.swift +++ b/Sources/Mori/App/TerminalAreaViewController.swift @@ -76,10 +76,7 @@ private final class WorkspaceGlassBackgroundView: NSView { /// running `tmux new-session -A -s ` to attach-or-create. @MainActor final class TerminalAreaViewController: NSViewController { - private static let tabBarHeight: CGFloat = 34 - private var glassBackgroundView: NSView? - private var tabsHostingController: NSHostingController? // MARK: - Dependencies @@ -139,7 +136,6 @@ final class TerminalAreaViewController: NSViewController { let container = NSView() container.wantsLayer = true self.view = container - installTabsViewIfNeeded() updateAppearance(themeInfo: themeInfo, isKeyWindow: true) showEmptyState() } @@ -152,25 +148,6 @@ final class TerminalAreaViewController: NSViewController { } } - func configureTabs( - appState: AppState, - onSelectWindow: @escaping (String) -> Void, - onCloseWindow: @escaping (String) -> Void, - onCreateWindow: @escaping () -> Void - ) { - let tabsView = TerminalTabsBarView( - appState: appState, - onSelectWindow: onSelectWindow, - onCloseWindow: onCloseWindow, - onCreateWindow: onCreateWindow - ) - let controller = NSHostingController(rootView: tabsView) - controller.sizingOptions = [] - tabsHostingController = controller - addChild(controller) - installTabsViewIfNeeded() - } - func updateAppearance(themeInfo: GhosttyThemeInfo, isKeyWindow: Bool) { view.layer?.backgroundColor = backgroundColor(for: themeInfo).cgColor updateGlassEffectIfNeeded(themeInfo: themeInfo, isKeyWindow: isKeyWindow) @@ -410,26 +387,7 @@ final class TerminalAreaViewController: NSViewController { } private var terminalContentTopAnchor: NSLayoutYAxisAnchor { - if let tabsView = tabsHostingController?.view, tabsView.superview === view { - return tabsView.bottomAnchor - } - return view.topAnchor - } - - private func installTabsViewIfNeeded() { - guard isViewLoaded, - let controller = tabsHostingController, - controller.view.superview == nil else { return } - - let tabsView = controller.view - tabsView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tabsView) - NSLayoutConstraint.activate([ - tabsView.topAnchor.constraint(equalTo: view.topAnchor), - tabsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tabsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tabsView.heightAnchor.constraint(equalToConstant: Self.tabBarHeight), - ]) + view.topAnchor } @objc private func emptyStateButtonClicked() { @@ -536,8 +494,7 @@ final class TerminalAreaViewController: NSViewController { /// Defensive cleanup in case a dead surface view was not tracked as current. private func removeResidualTerminalSubviews() { - let tabsView = tabsHostingController?.view - for subview in view.subviews where subview !== emptyStateView && subview !== tabsView { + for subview in view.subviews where subview !== emptyStateView { if subview !== currentSurface { subview.removeFromSuperview() } @@ -620,115 +577,3 @@ final class TerminalAreaViewController: NSViewController { } } } - -private struct TerminalTabsBarView: View { - @Bindable var appState: AppState - let onSelectWindow: (String) -> Void - let onCloseWindow: (String) -> Void - let onCreateWindow: () -> Void - - private var windows: [RuntimeWindow] { - appState.windowsForSelectedWorktree - } - - var body: some View { - HStack(alignment: .bottom, spacing: 0) { - ForEach(windows) { window in - terminalTab(for: window) - } - - Button(action: onCreateWindow) { - Image(systemName: "plus") - .font(.system(size: 13, weight: .semibold)) - .frame(width: 28, height: 26) - .foregroundStyle(MoriTokens.Color.muted) - .background(Color.primary.opacity(MoriTokens.Opacity.quiet)) - .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.small)) - } - .buttonStyle(.plain) - .help(String.localized("New Tab")) - .accessibilityLabel(String.localized("New Tab")) - .padding(.leading, MoriTokens.Spacing.sm) - .padding(.bottom, 5) - - Spacer(minLength: 0) - } - .padding(.leading, MoriTokens.Spacing.xl) - .padding(.trailing, MoriTokens.Spacing.lg) - .background(Color.clear) - } - - private func terminalTab(for window: RuntimeWindow) -> some View { - let isSelected = window.tmuxWindowId == appState.uiState.selectedWindowId - - return HStack(spacing: MoriTokens.Spacing.sm) { - Circle() - .fill(tabDotColor(for: window, isSelected: isSelected)) - .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) - - Text(tabTitle(for: window)) - .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(isSelected ? Color.primary : MoriTokens.Color.muted) - .frame(maxWidth: .infinity, alignment: .leading) - - if isSelected { - Button(action: { onCloseWindow(window.tmuxWindowId) }) { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(MoriTokens.Color.muted) - .frame(width: 16, height: 16) - .background(Color.primary.opacity(MoriTokens.Opacity.quiet)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .help(String.localized("Close Tab")) - .accessibilityLabel(String.localized("Close Tab")) - } - } - .padding(.horizontal, MoriTokens.Spacing.lg) - .frame(width: 170, height: 32) - .contentShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.medium, style: .continuous)) - .onTapGesture { - onSelectWindow(window.tmuxWindowId) - } - .background( - RoundedRectangle( - cornerRadius: MoriTokens.Radius.medium, - style: .continuous - ) - .fill(isSelected ? Color.primary.opacity(0.11) : Color.primary.opacity(0.055)) - ) - .overlay(alignment: .bottom) { - if isSelected { - Rectangle() - .fill((Color(nsColor: .windowBackgroundColor))) - .frame(height: 1) - } - } - .overlay { - RoundedRectangle(cornerRadius: MoriTokens.Radius.medium, style: .continuous) - .strokeBorder(isSelected ? Color.primary.opacity(0.16) : Color.clear, lineWidth: 1) - } - .padding(.top, 2) - } - - private func tabTitle(for window: RuntimeWindow) -> String { - if !window.title.isEmpty { - return window.title - } - return String.localized("Window \(window.tmuxWindowIndex)") - } - - private func tabDotColor(for window: RuntimeWindow, isSelected: Bool) -> Color { - if isSelected { return MoriTokens.Color.active } - if window.detectedAgent != nil || window.agentState != .none { return MoriTokens.Color.info } - if window.hasUnreadOutput { return MoriTokens.Color.attention } - switch window.tag { - case .server: return MoriTokens.Color.success - case .agent: return MoriTokens.Color.info - default: return MoriTokens.Color.inactive - } - } -} diff --git a/Sources/Mori/App/TerminalTabsBarView.swift b/Sources/Mori/App/TerminalTabsBarView.swift new file mode 100644 index 0000000..b321c5a --- /dev/null +++ b/Sources/Mori/App/TerminalTabsBarView.swift @@ -0,0 +1,362 @@ +import AppKit +import Observation +import MoriCore +import MoriUI + +/// Native AppKit terminal tabs for the window titlebar toolbar. +/// +/// This intentionally avoids SwiftUI hosting inside `NSToolbarItem`: the toolbar's +/// flexible-space layout has repeatedly rendered hosted SwiftUI as a blank strip. +@MainActor +final class TerminalTabsBarView: NSView { + private static let tabWidth: CGFloat = 160 + private static let tabHeight: CGFloat = 24 + private static let plusWidth: CGFloat = 26 + private static let horizontalPadding = MoriTokens.Spacing.sm + private static let spacing = MoriTokens.Spacing.xs + + private let appState: AppState + private let onSelectWindow: (String) -> Void + private let onCloseWindow: (String) -> Void + private let onCreateWindow: () -> Void + private let stackView = NSStackView() + private lazy var widthConstraint = widthAnchor.constraint(equalToConstant: contentWidth) + private lazy var heightConstraint = heightAnchor.constraint(equalToConstant: TerminalTabsBarView.tabHeight) + + private var contentWidth: CGFloat = TerminalTabsBarView.horizontalPadding * 2 + TerminalTabsBarView.plusWidth + private var stripWidth: CGFloat = TerminalTabsBarView.horizontalPadding * 2 + TerminalTabsBarView.plusWidth + var onIntrinsicContentSizeChanged: (() -> Void)? + + init( + appState: AppState, + onSelectWindow: @escaping (String) -> Void, + onCloseWindow: @escaping (String) -> Void, + onCreateWindow: @escaping () -> Void + ) { + self.appState = appState + self.onSelectWindow = onSelectWindow + self.onCloseWindow = onCloseWindow + self.onCreateWindow = onCreateWindow + super.init(frame: .zero) + setupView() + updateAndObserve() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isFlipped: Bool { true } + + override var intrinsicContentSize: NSSize { + NSSize(width: ceil(max(stripWidth, contentWidth)), height: Self.tabHeight) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + stackView.arrangedSubviews.forEach { $0.needsDisplay = true } + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) + NSLayoutConstraint.activate([widthConstraint, heightConstraint]) + + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.distribution = .fill + stackView.spacing = Self.spacing + stackView.edgeInsets = NSEdgeInsets( + top: 0, + left: Self.horizontalPadding, + bottom: 0, + right: Self.horizontalPadding + ) + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func updateAndObserve() { + withObservationTracking { + rebuildTabs( + windows: appState.windowsForSelectedWorktree, + selectedWindowId: appState.uiState.selectedWindowId + ) + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.updateAndObserve() + } + } + } + + private func rebuildTabs(windows: [RuntimeWindow], selectedWindowId: String?) { + stackView.arrangedSubviews.forEach { view in + stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for window in windows { + let tab = TerminalTabControl( + window: window, + isSelected: window.tmuxWindowId == selectedWindowId, + onSelect: onSelectWindow, + onClose: onCloseWindow + ) + stackView.addArrangedSubview(tab) + } + + let addButton = TerminalIconButton( + symbolName: "plus", + size: NSSize(width: Self.plusWidth, height: Self.tabHeight), + cornerRadius: MoriTokens.Radius.small, + pointSize: 12, + weight: .semibold, + accessibilityLabel: String.localized("New Tab"), + onPress: onCreateWindow + ) + stackView.addArrangedSubview(addButton) + + let slackSpacer = NSView() + slackSpacer.translatesAutoresizingMaskIntoConstraints = false + slackSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + slackSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let slackWidth = slackSpacer.widthAnchor.constraint(equalToConstant: 10_000) + slackWidth.priority = .defaultLow + NSLayoutConstraint.activate([ + slackWidth, + slackSpacer.heightAnchor.constraint(equalToConstant: Self.tabHeight), + ]) + stackView.addArrangedSubview(slackSpacer) + + let itemCount = windows.count + 1 + contentWidth = Self.horizontalPadding * 2 + + CGFloat(windows.count) * Self.tabWidth + + Self.plusWidth + + CGFloat(max(0, itemCount)) * Self.spacing + syncStripWidth() + } + + func setStripWidth(_ width: CGFloat) { + let width = ceil(max(width, contentWidth)) + guard abs(width - stripWidth) > 0.5 else { return } + stripWidth = width + syncStripWidth() + } + + private func syncStripWidth() { + widthConstraint.constant = max(stripWidth, contentWidth) + heightConstraint.constant = Self.tabHeight + invalidateIntrinsicContentSize() + needsLayout = true + onIntrinsicContentSizeChanged?() + } +} + +@MainActor +private final class TerminalTabControl: NSControl { + private static let width: CGFloat = 160 + private static let height: CGFloat = 24 + + private let runtimeWindow: RuntimeWindow + private let selected: Bool + private let onSelect: (String) -> Void + private let onClose: (String) -> Void + private let closeButton: TerminalIconButton + + init( + window: RuntimeWindow, + isSelected: Bool, + onSelect: @escaping (String) -> Void, + onClose: @escaping (String) -> Void + ) { + self.runtimeWindow = window + self.selected = isSelected + self.onSelect = onSelect + self.onClose = onClose + self.closeButton = TerminalIconButton( + symbolName: "xmark", + size: NSSize(width: 16, height: 16), + cornerRadius: 8, + pointSize: 10, + weight: .bold, + accessibilityLabel: String.localized("Close Tab"), + onPress: { onClose(window.tmuxWindowId) } + ) + super.init(frame: NSRect(x: 0, y: 0, width: Self.width, height: Self.height)) + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: Self.width), + heightAnchor.constraint(equalToConstant: Self.height), + ]) + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) + addSubview(closeButton) + closeButton.isHidden = !isSelected + toolTip = tabTitle(for: window) + setAccessibilityRole(.button) + setAccessibilityLabel(tabTitle(for: window)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isFlipped: Bool { true } + + override var intrinsicContentSize: NSSize { + NSSize(width: Self.width, height: Self.height) + } + + override func layout() { + super.layout() + closeButton.frame = NSRect(x: bounds.maxX - MoriTokens.Spacing.md - 16, y: (bounds.height - 16) / 2, width: 16, height: 16) + } + + override func mouseDown(with event: NSEvent) { + onSelect(runtimeWindow.tmuxWindowId) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let pillRect = bounds + let path = NSBezierPath(roundedRect: pillRect, xRadius: MoriTokens.Radius.medium, yRadius: MoriTokens.Radius.medium) + NSColor.labelColor.withAlphaComponent(selected ? 0.11 : 0.055).setFill() + path.fill() + + if selected { + let strokePath = NSBezierPath(roundedRect: pillRect.insetBy(dx: 0.5, dy: 0.5), xRadius: MoriTokens.Radius.medium, yRadius: MoriTokens.Radius.medium) + strokePath.lineWidth = 1 + NSColor.labelColor.withAlphaComponent(0.16).setStroke() + strokePath.stroke() + } + + drawDot() + drawTitle() + } + + private func drawDot() { + let dotSize = MoriTokens.Icon.dot + let rect = NSRect( + x: MoriTokens.Spacing.md, + y: (bounds.height - dotSize) / 2, + width: dotSize, + height: dotSize + ) + tabDotColor(for: runtimeWindow, isSelected: selected).setFill() + NSBezierPath(ovalIn: rect).fill() + } + + private func drawTitle() { + let font = NSFont.systemFont(ofSize: 13, weight: selected ? .semibold : .medium) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byTruncatingTail + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: selected ? NSColor.labelColor : NSColor.secondaryLabelColor, + .paragraphStyle: paragraphStyle, + ] + let attributedTitle = NSAttributedString(string: tabTitle(for: runtimeWindow), attributes: attributes) + let closeWidth = selected ? 16 + MoriTokens.Spacing.sm : 0 + let textX = MoriTokens.Spacing.md + MoriTokens.Icon.dot + MoriTokens.Spacing.md + let textRect = NSRect( + x: textX, + y: (bounds.height - font.ascender + font.descender) / 2 - 1, + width: bounds.width - textX - MoriTokens.Spacing.md - closeWidth, + height: bounds.height + ) + attributedTitle.draw(in: textRect) + } + + private func tabTitle(for window: RuntimeWindow) -> String { + if !window.title.isEmpty { + return window.title + } + return String.localized("Window \(window.tmuxWindowIndex)") + } + + private func tabDotColor(for window: RuntimeWindow, isSelected: Bool) -> NSColor { + if isSelected { return .controlAccentColor } + if window.detectedAgent != nil || window.agentState != .none { return .systemBlue } + if window.hasUnreadOutput { return .systemYellow } + switch window.tag { + case .server: return .systemGreen + case .agent: return .systemBlue + default: return .systemGray + } + } +} + +@MainActor +private final class TerminalIconButton: NSButton { + private let fixedSize: NSSize + private let cornerRadius: CGFloat + private let onPress: () -> Void + + init( + symbolName: String, + size: NSSize, + cornerRadius: CGFloat, + pointSize: CGFloat, + weight: NSFont.Weight, + accessibilityLabel: String, + onPress: @escaping () -> Void + ) { + self.fixedSize = size + self.cornerRadius = cornerRadius + self.onPress = onPress + let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight)) ?? NSImage() + super.init(frame: NSRect(origin: .zero, size: size)) + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size.width), + heightAnchor.constraint(equalToConstant: size.height), + ]) + self.image = image + self.imagePosition = .imageOnly + self.imageScaling = .scaleProportionallyDown + self.isBordered = false + self.bezelStyle = .regularSquare + self.target = self + self.action = #selector(press) + self.contentTintColor = .secondaryLabelColor + self.setAccessibilityLabel(accessibilityLabel) + self.toolTip = accessibilityLabel + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { fixedSize } + + override func draw(_ dirtyRect: NSRect) { + NSColor.labelColor.withAlphaComponent(MoriTokens.Opacity.quiet).setFill() + NSBezierPath(roundedRect: bounds, xRadius: cornerRadius, yRadius: cornerRadius).fill() + super.draw(dirtyRect) + } + + @objc private func press() { + onPress() + } +} diff --git a/Sources/Mori/Resources/en.lproj/Localizable.strings b/Sources/Mori/Resources/en.lproj/Localizable.strings index 49c5bdf..a8b278d 100644 --- a/Sources/Mori/Resources/en.lproj/Localizable.strings +++ b/Sources/Mori/Resources/en.lproj/Localizable.strings @@ -122,6 +122,7 @@ "Trying to restore remote session..." = "Trying to restore remote session..."; "This will remove the project and all its worktrees from Mori. Git repositories on disk will not be deleted." = "This will remove the project and all its worktrees from Mori. Git repositories on disk will not be deleted."; "This worktree is at %@" = "This worktree is at %@"; +"Tabs" = "Tabs"; "Tmux" = "Tmux"; "Toggle Full Screen" = "Toggle Full Screen"; "Toggle Pane Zoom" = "Toggle Pane Zoom"; diff --git a/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings index ffa7086..348bd48 100644 --- a/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Mori/Resources/zh-Hans.lproj/Localizable.strings @@ -122,6 +122,7 @@ "Trying to restore remote session..." = "正在尝试恢复远端会话..."; "This will remove the project and all its worktrees from Mori. Git repositories on disk will not be deleted." = "此操作将从 Mori 移除该项目及其所有工作树。磁盘上的 Git 仓库不会被删除。"; "This worktree is at %@" = "此工作树位于 %@"; +"Tabs" = "标签页"; "Tmux" = "Tmux"; "Toggle Full Screen" = "切换全屏"; "Toggle Pane Zoom" = "切换窗格缩放";