From 6d93f7abdbf18bec7df5d09dd6f664d3bf2452bd Mon Sep 17 00:00:00 2001 From: kim-raaschou Date: Sun, 15 Feb 2026 17:25:38 +0100 Subject: [PATCH] Add i3OrderedWithAppIcons menu bar style with per-workspace app icons --- .../AppBundle/ui/ExperimentalUISettings.swift | 2 + Sources/AppBundle/ui/MenuBarLabel.swift | 64 ++++++++++++++----- Sources/AppBundle/ui/TrayMenuModel.swift | 50 +++++++++++++++ 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/Sources/AppBundle/ui/ExperimentalUISettings.swift b/Sources/AppBundle/ui/ExperimentalUISettings.swift index 7b8e18c98..d9ac93103 100644 --- a/Sources/AppBundle/ui/ExperimentalUISettings.swift +++ b/Sources/AppBundle/ui/ExperimentalUISettings.swift @@ -22,6 +22,7 @@ enum MenuBarStyle: String, CaseIterable, Identifiable, Equatable, Hashable { case squares case i3 case i3Ordered + case i3OrderedWithAppIcons var id: String { rawValue } var title: String { switch self { @@ -30,6 +31,7 @@ enum MenuBarStyle: String, CaseIterable, Identifiable, Equatable, Hashable { case .squares: "Square images" case .i3: "i3 style grouped" case .i3Ordered: "i3 style ordered" + case .i3OrderedWithAppIcons: "i3 style ordered + app icons" } } } diff --git a/Sources/AppBundle/ui/MenuBarLabel.swift b/Sources/AppBundle/ui/MenuBarLabel.swift index 44ffc40d7..0d34162fc 100644 --- a/Sources/AppBundle/ui/MenuBarLabel.swift +++ b/Sources/AppBundle/ui/MenuBarLabel.swift @@ -53,22 +53,9 @@ struct MenuBarLabel: View { otherWorkspaces(with: workspaces) } case .i3Ordered: - let modeItem = viewModel.trayItems.first { $0.type == .mode } - if let modeItem { - itemView(for: modeItem) - modeSeparator(with: .monospaced) - } - let orderedWorkspaces = viewModel.workspaces.filter { !$0.isEffectivelyEmpty || $0.isVisible } - ForEach(orderedWorkspaces, id: \.name) { item in - let trayItem = TrayItem( - type: .workspace, - name: item.name, - isActive: item.isFocused, - hasFullscreenWindows: item.hasFullscreenWindows, - ) - itemView(for: trayItem) - .opacity(item.isVisible ? 1 : 0.5) - } + orderedWorkspacesView(showApps: false) + case .i3OrderedWithAppIcons: + orderedWorkspacesView(showApps: true) } } } @@ -168,6 +155,51 @@ struct MenuBarLabel: View { } } } + + private func orderedWorkspacesView(showApps: Bool) -> some View { + let modeItem = viewModel.trayItems.first { $0.type == .mode } + let orderedWorkspaces = viewModel.workspaces.filter { !$0.isEffectivelyEmpty || $0.isVisible } + return Group { + if let modeItem { + itemView(for: modeItem) + modeSeparator(with: .monospaced) + } + ForEach(orderedWorkspaces, id: \.name) { ws in + let trayItem = TrayItem( + type: .workspace, + name: ws.name, + isActive: ws.isFocused, + hasFullscreenWindows: ws.hasFullscreenWindows, + ) + itemView(for: trayItem) + .opacity(ws.isVisible ? 1 : 0.5) + if showApps { + let limit = style != nil ? (ws.isFocused ? 1 : 0) : ws.apps.count + ForEach(ws.apps.prefix(limit)) { app in + appIconView(for: app) + .opacity(ws.isFocused && app.isFocused ? 1 : 0.5) + } + } + } + } + } + + @ViewBuilder + private func appIconView(for app: AppViewModel) -> some View { + let icon = Image(nsImage: app.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: itemSize, height: itemSize) + if app.isFocused { + icon.clipShape(RoundedRectangle(cornerRadius: itemCornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: itemCornerRadius, style: .continuous) + .strokeBorder(finalColor, lineWidth: 1) + } + } else { + icon + } + } } extension String { diff --git a/Sources/AppBundle/ui/TrayMenuModel.swift b/Sources/AppBundle/ui/TrayMenuModel.swift index 367ccb4a4..ff714f524 100644 --- a/Sources/AppBundle/ui/TrayMenuModel.swift +++ b/Sources/AppBundle/ui/TrayMenuModel.swift @@ -35,6 +35,20 @@ public final class TrayMenuModel: ObservableObject { default: "" } let hasFullscreenWindows = $0.allLeafWindowsRecursive.contains { $0.isFullscreen } + let appViewModels: [AppViewModel] + if TrayMenuModel.shared.experimentalUISettings.displayStyle == .i3OrderedWithAppIcons { + let focusedWindowId = focus.windowOrNil?.windowId + appViewModels = $0.allLeafWindowsRecursive.map { window in + AppViewModel( + windowId: window.windowId, + name: window.app.name ?? "Unknown", + icon: resolveAppIcon(for: window), + isFocused: window.windowId == focusedWindowId, + ) + } + } else { + appViewModels = [] + } return WorkspaceViewModel( name: $0.name, suffix: suffix, @@ -42,6 +56,7 @@ public final class TrayMenuModel: ObservableObject { isEffectivelyEmpty: $0.isEffectivelyEmpty, isVisible: $0.isVisible, hasFullscreenWindows: hasFullscreenWindows, + apps: appViewModels, ) } var items = sortedMonitors.map { @@ -69,6 +84,23 @@ struct WorkspaceViewModel: Hashable { let isEffectivelyEmpty: Bool let isVisible: Bool let hasFullscreenWindows: Bool + let apps: [AppViewModel] + + func hash(into hasher: inout Hasher) { + hasher.combine(name) + } +} + +struct AppViewModel: Identifiable, Equatable { + let windowId: UInt32 + let name: String + let icon: NSImage + let isFocused: Bool + var id: UInt32 { windowId } + + static func == (lhs: AppViewModel, rhs: AppViewModel) -> Bool { + lhs.windowId == rhs.windowId && lhs.isFocused == rhs.isFocused + } } enum TrayItemType: String, Hashable { @@ -108,3 +140,21 @@ struct TrayItem: Hashable, Identifiable { return type.rawValue + name } } + +@MainActor +private func resolveAppIcon(for window: Window) -> NSImage { + if let macApp = window.app as? MacApp { + if let icon = macApp.nsApp.icon { + return icon + } + if let bundlePath = macApp.bundlePath { + return NSWorkspace.shared.icon(forFile: bundlePath) + } + if let bundleId = macApp.rawAppBundleId, + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) + { + return NSWorkspace.shared.icon(forFile: url.path) + } + } + return NSImage(named: NSImage.applicationIconName) ?? NSImage() +}