Use NSPopover for notifications
This commit is contained in:
parent
46dd00adac
commit
c5d6065664
3 changed files with 179 additions and 26 deletions
|
|
@ -133,8 +133,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
titlebarAccessoryController.attach(to: window)
|
||||
}
|
||||
|
||||
func toggleNotificationsPopover() {
|
||||
titlebarAccessoryController.toggleNotificationsPopover()
|
||||
func toggleNotificationsPopover(animated: Bool = true) {
|
||||
titlebarAccessoryController.toggleNotificationsPopover(animated: animated)
|
||||
}
|
||||
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
|
|
|
|||
|
|
@ -193,7 +193,25 @@ struct TitlebarControlsStyleConfig {
|
|||
}
|
||||
|
||||
final class TitlebarControlsViewModel: ObservableObject {
|
||||
@Published var isShowingNotifications = false
|
||||
weak var notificationsAnchorView: NSView?
|
||||
}
|
||||
|
||||
private struct NotificationsAnchorView: NSViewRepresentable {
|
||||
let onResolve: (NSView) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView(frame: .zero)
|
||||
DispatchQueue.main.async {
|
||||
onResolve(view)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
onResolve(nsView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TitlebarControlButton<Content: View>: View {
|
||||
|
|
@ -224,6 +242,7 @@ private struct TitlebarControlsView: View {
|
|||
@ObservedObject var notificationStore: TerminalNotificationStore
|
||||
@ObservedObject var viewModel: TitlebarControlsViewModel
|
||||
let onToggleSidebar: () -> Void
|
||||
let onToggleNotifications: () -> Void
|
||||
let onNewTab: () -> Void
|
||||
@AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue
|
||||
|
||||
|
|
@ -241,8 +260,9 @@ private struct TitlebarControlsView: View {
|
|||
iconLabel(systemName: "sidebar.left", config: config)
|
||||
}
|
||||
.accessibilityLabel("Toggle Sidebar")
|
||||
.help("Show or hide the sidebar (Cmd+B)")
|
||||
|
||||
TitlebarControlButton(config: config, action: { viewModel.isShowingNotifications.toggle() }) {
|
||||
TitlebarControlButton(config: config, action: onToggleNotifications) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
iconLabel(systemName: "bell", config: config)
|
||||
|
||||
|
|
@ -259,15 +279,15 @@ private struct TitlebarControlsView: View {
|
|||
}
|
||||
.frame(width: config.buttonSize, height: config.buttonSize)
|
||||
}
|
||||
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
|
||||
.accessibilityLabel("Notifications")
|
||||
.popover(isPresented: $viewModel.isShowingNotifications, arrowEdge: .top) {
|
||||
NotificationsPopoverView(notificationStore: notificationStore)
|
||||
}
|
||||
.help("Show notifications (Cmd+Shift+I)")
|
||||
|
||||
TitlebarControlButton(config: config, action: onNewTab) {
|
||||
iconLabel(systemName: "plus", config: config)
|
||||
}
|
||||
.accessibilityLabel("New Tab")
|
||||
.help("Open a new tab (Cmd+T or Cmd+N)")
|
||||
}
|
||||
|
||||
let paddedContent = content.padding(config.groupPadding)
|
||||
|
|
@ -305,15 +325,19 @@ private struct TitlebarControlsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController {
|
||||
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate {
|
||||
private let hostingView: NonDraggableHostingView<TitlebarControlsView>
|
||||
private let containerView = NSView()
|
||||
private let notificationStore: TerminalNotificationStore
|
||||
private lazy var notificationsPopover: NSPopover = makeNotificationsPopover()
|
||||
private var pendingSizeUpdate = false
|
||||
private let viewModel = TitlebarControlsViewModel()
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
|
||||
init(notificationStore: TerminalNotificationStore) {
|
||||
self.notificationStore = notificationStore
|
||||
let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() }
|
||||
let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) }
|
||||
let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() }
|
||||
|
||||
hostingView = NonDraggableHostingView(
|
||||
|
|
@ -321,6 +345,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
notificationStore: notificationStore,
|
||||
viewModel: viewModel,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onToggleNotifications: toggleNotifications,
|
||||
onNewTab: newTab
|
||||
)
|
||||
)
|
||||
|
|
@ -387,8 +412,25 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
|
||||
}
|
||||
|
||||
func toggleNotificationsPopover() {
|
||||
viewModel.isShowingNotifications.toggle()
|
||||
func toggleNotificationsPopover(animated: Bool = true) {
|
||||
if notificationsPopover.isShown {
|
||||
notificationsPopover.performClose(nil)
|
||||
return
|
||||
}
|
||||
let anchorView = viewModel.notificationsAnchorView ?? hostingView
|
||||
notificationsPopover.animates = animated
|
||||
notificationsPopover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxY)
|
||||
}
|
||||
|
||||
private func makeNotificationsPopover() -> NSPopover {
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .semitransient
|
||||
popover.animates = true
|
||||
popover.delegate = self
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: NotificationsPopoverView(notificationStore: notificationStore)
|
||||
)
|
||||
return popover
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -731,9 +773,9 @@ final class UpdateTitlebarAccessoryController {
|
|||
}
|
||||
}
|
||||
|
||||
func toggleNotificationsPopover() {
|
||||
func toggleNotificationsPopover(animated: Bool = true) {
|
||||
for controller in controlsControllers.allObjects {
|
||||
controller.toggleNotificationsPopover()
|
||||
controller.toggleNotificationsPopover(animated: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,18 @@ struct cmuxApp: App {
|
|||
@StateObject private var sidebarState = SidebarState()
|
||||
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
|
||||
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
|
||||
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
// Start the terminal controller for programmatic control
|
||||
// This runs after TabManager is created via @StateObject
|
||||
let defaults = UserDefaults.standard
|
||||
if defaults.object(forKey: SocketControlSettings.appStorageKey) == nil,
|
||||
let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
|
||||
defaults.set(legacy ? SocketControlMode.full.rawValue : SocketControlMode.off.rawValue,
|
||||
forKey: SocketControlSettings.appStorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
|
@ -23,18 +30,27 @@ struct cmuxApp: App {
|
|||
.environmentObject(sidebarState)
|
||||
.onAppear {
|
||||
// Start the Unix socket controller for programmatic access
|
||||
TerminalController.shared.start(tabManager: tabManager)
|
||||
updateSocketController()
|
||||
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
|
||||
applyAppearance()
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" {
|
||||
DispatchQueue.main.async {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: appearanceMode) { _ in
|
||||
applyAppearance()
|
||||
}
|
||||
.onChange(of: socketControlMode) { _ in
|
||||
updateSocketController()
|
||||
}
|
||||
}
|
||||
.windowToolbarStyle(.automatic)
|
||||
Settings {
|
||||
SettingsRootView()
|
||||
}
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About cmuxterm") {
|
||||
|
|
@ -104,7 +120,7 @@ struct cmuxApp: App {
|
|||
// Close tab
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Close Panel") {
|
||||
tabManager.closeCurrentPanelWithConfirmation()
|
||||
closePanelOrWindow()
|
||||
}
|
||||
.keyboardShortcut("w", modifiers: .command)
|
||||
|
||||
|
|
@ -190,13 +206,16 @@ struct cmuxApp: App {
|
|||
private func applyAppearance() {
|
||||
guard let mode = AppearanceMode(rawValue: appearanceMode) else { return }
|
||||
switch mode {
|
||||
case .auto:
|
||||
NSApp.appearance = nil
|
||||
case .system:
|
||||
let match = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) ?? .aqua
|
||||
NSApp.appearance = NSAppearance(named: match)
|
||||
NSApp.appearance = nil
|
||||
case .light:
|
||||
NSApp.appearance = NSAppearance(named: .aqua)
|
||||
case .dark:
|
||||
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||
case .auto:
|
||||
// Legacy value; treat like system and migrate.
|
||||
NSApp.appearance = nil
|
||||
appearanceMode = AppearanceMode.system.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,36 +224,110 @@ struct cmuxApp: App {
|
|||
tabManager.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
|
||||
}
|
||||
|
||||
private func updateSocketController() {
|
||||
let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode)
|
||||
if mode != .off {
|
||||
TerminalController.shared.start(
|
||||
tabManager: tabManager,
|
||||
socketPath: SocketControlSettings.socketPath(),
|
||||
accessMode: mode
|
||||
)
|
||||
} else {
|
||||
TerminalController.shared.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSocketMode: SocketControlMode {
|
||||
SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode
|
||||
}
|
||||
|
||||
private func closePanelOrWindow() {
|
||||
if let window = NSApp.keyWindow,
|
||||
window.identifier?.rawValue == "cmux.settings" {
|
||||
window.performClose(nil)
|
||||
return
|
||||
}
|
||||
tabManager.closeCurrentPanelWithConfirmation()
|
||||
}
|
||||
|
||||
private func showNotificationsPopover() {
|
||||
AppDelegate.shared?.toggleNotificationsPopover()
|
||||
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppearanceMode: String, CaseIterable, Identifiable {
|
||||
case auto
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
case auto
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
static var visibleCases: [AppearanceMode] {
|
||||
[.system, .light, .dark]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .system:
|
||||
return "System"
|
||||
case .light:
|
||||
return "Light"
|
||||
case .dark:
|
||||
return "Dark"
|
||||
case .auto:
|
||||
return "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
|
||||
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Theme")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Theme", selection: $appearanceMode) {
|
||||
Text("Auto").tag(AppearanceMode.auto.rawValue)
|
||||
Text("System").tag(AppearanceMode.system.rawValue)
|
||||
Text("Dark").tag(AppearanceMode.dark.rawValue)
|
||||
Picker("", selection: $appearanceMode) {
|
||||
ForEach(AppearanceMode.visibleCases) { mode in
|
||||
Text(mode.displayName).tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Automation")
|
||||
.font(.headline)
|
||||
|
||||
Picker("", selection: $socketControlMode) {
|
||||
ForEach(SocketControlMode.allCases) { mode in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(mode.displayName)
|
||||
Text(mode.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
.accessibilityIdentifier("AutomationSocketModePicker")
|
||||
|
||||
Text("Expose a local Unix socket for programmatic control. This can be a security risk on shared machines.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 360, minHeight: 180)
|
||||
.frame(minWidth: 360, minHeight: 280)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +335,25 @@ private struct SettingsRootView: View {
|
|||
var body: some View {
|
||||
SettingsView()
|
||||
.background(WindowAccessor { window in
|
||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
||||
configureSettingsWindow(window)
|
||||
})
|
||||
}
|
||||
|
||||
private func configureSettingsWindow(_ window: NSWindow) {
|
||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.styleMask.insert(.resizable)
|
||||
window.contentMinSize = NSSize(width: 360, height: 280)
|
||||
if window.frame.width > 520 {
|
||||
window.setContentSize(NSSize(width: 460, height: max(280, window.contentView?.frame.height ?? 280)))
|
||||
}
|
||||
|
||||
let accessories = window.titlebarAccessoryViewControllers
|
||||
for index in accessories.indices.reversed() {
|
||||
guard let identifier = accessories[index].view.identifier?.rawValue else { continue }
|
||||
guard identifier.hasPrefix("cmux.") else { continue }
|
||||
window.removeTitlebarAccessoryViewController(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue