Skip to content

Commit de6126c

Browse files
committed
Add i3OrderedWithAppIcons menu bar style with per-workspace app icons
1 parent 18545c2 commit de6126c

File tree

3 files changed

+100
-16
lines changed

3 files changed

+100
-16
lines changed

Sources/AppBundle/ui/ExperimentalUISettings.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ enum MenuBarStyle: String, CaseIterable, Identifiable, Equatable, Hashable {
2222
case squares
2323
case i3
2424
case i3Ordered
25+
case i3OrderedWithAppIcons
2526
var id: String { rawValue }
2627
var title: String {
2728
switch self {
@@ -30,6 +31,7 @@ enum MenuBarStyle: String, CaseIterable, Identifiable, Equatable, Hashable {
3031
case .squares: "Square images"
3132
case .i3: "i3 style grouped"
3233
case .i3Ordered: "i3 style ordered"
34+
case .i3OrderedWithAppIcons: "i3 style ordered + app icons"
3335
}
3436
}
3537
}

Sources/AppBundle/ui/MenuBarLabel.swift

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,9 @@ struct MenuBarLabel: View {
5353
otherWorkspaces(with: workspaces)
5454
}
5555
case .i3Ordered:
56-
let modeItem = viewModel.trayItems.first { $0.type == .mode }
57-
if let modeItem {
58-
itemView(for: modeItem)
59-
modeSeparator(with: .monospaced)
60-
}
61-
let orderedWorkspaces = viewModel.workspaces.filter { !$0.isEffectivelyEmpty || $0.isVisible }
62-
ForEach(orderedWorkspaces, id: \.name) { item in
63-
let trayItem = TrayItem(
64-
type: .workspace,
65-
name: item.name,
66-
isActive: item.isFocused,
67-
hasFullscreenWindows: item.hasFullscreenWindows,
68-
)
69-
itemView(for: trayItem)
70-
.opacity(item.isVisible ? 1 : 0.5)
71-
}
56+
orderedWorkspacesView(showApps: false)
57+
case .i3OrderedWithAppIcons:
58+
orderedWorkspacesView(showApps: true)
7259
}
7360
}
7461
}
@@ -168,6 +155,51 @@ struct MenuBarLabel: View {
168155
}
169156
}
170157
}
158+
159+
private func orderedWorkspacesView(showApps: Bool) -> some View {
160+
let modeItem = viewModel.trayItems.first { $0.type == .mode }
161+
let orderedWorkspaces = viewModel.workspaces.filter { !$0.isEffectivelyEmpty || $0.isVisible }
162+
return Group {
163+
if let modeItem {
164+
itemView(for: modeItem)
165+
modeSeparator(with: .monospaced)
166+
}
167+
ForEach(orderedWorkspaces, id: \.name) { ws in
168+
let trayItem = TrayItem(
169+
type: .workspace,
170+
name: ws.name,
171+
isActive: ws.isFocused,
172+
hasFullscreenWindows: ws.hasFullscreenWindows,
173+
)
174+
itemView(for: trayItem)
175+
.opacity(ws.isVisible ? 1 : 0.5)
176+
if showApps {
177+
let limit = style != nil ? (ws.isFocused ? 1 : 0) : ws.apps.count
178+
ForEach(ws.apps.prefix(limit)) { app in
179+
appIconView(for: app)
180+
.opacity(ws.isFocused ? 1 : 0.5)
181+
}
182+
}
183+
}
184+
}
185+
}
186+
187+
@ViewBuilder
188+
private func appIconView(for app: AppViewModel) -> some View {
189+
let icon = Image(nsImage: app.icon)
190+
.resizable()
191+
.aspectRatio(contentMode: .fit)
192+
.frame(width: itemSize, height: itemSize)
193+
if app.isFocused {
194+
icon.clipShape(RoundedRectangle(cornerRadius: itemCornerRadius, style: .continuous))
195+
.overlay {
196+
RoundedRectangle(cornerRadius: itemCornerRadius, style: .continuous)
197+
.strokeBorder(finalColor, lineWidth: 1)
198+
}
199+
} else {
200+
icon
201+
}
202+
}
171203
}
172204

173205
extension String {

Sources/AppBundle/ui/TrayMenuModel.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,28 @@ public final class TrayMenuModel: ObservableObject {
3535
default: ""
3636
}
3737
let hasFullscreenWindows = $0.allLeafWindowsRecursive.contains { $0.isFullscreen }
38+
let appViewModels: [AppViewModel]
39+
if TrayMenuModel.shared.experimentalUISettings.displayStyle == .i3OrderedWithAppIcons {
40+
let focusedWindowId = focus.windowOrNil?.windowId
41+
appViewModels = $0.allLeafWindowsRecursive.map { window in
42+
AppViewModel(
43+
windowId: window.windowId,
44+
name: window.app.name ?? "Unknown",
45+
icon: resolveAppIcon(for: window),
46+
isFocused: window.windowId == focusedWindowId,
47+
)
48+
}
49+
} else {
50+
appViewModels = []
51+
}
3852
return WorkspaceViewModel(
3953
name: $0.name,
4054
suffix: suffix,
4155
isFocused: focus.workspace == $0,
4256
isEffectivelyEmpty: $0.isEffectivelyEmpty,
4357
isVisible: $0.isVisible,
4458
hasFullscreenWindows: hasFullscreenWindows,
59+
apps: appViewModels,
4560
)
4661
}
4762
var items = sortedMonitors.map {
@@ -69,6 +84,23 @@ struct WorkspaceViewModel: Hashable {
6984
let isEffectivelyEmpty: Bool
7085
let isVisible: Bool
7186
let hasFullscreenWindows: Bool
87+
let apps: [AppViewModel]
88+
89+
func hash(into hasher: inout Hasher) {
90+
hasher.combine(name)
91+
}
92+
}
93+
94+
struct AppViewModel: Identifiable, Equatable {
95+
let windowId: UInt32
96+
let name: String
97+
let icon: NSImage
98+
let isFocused: Bool
99+
var id: UInt32 { windowId }
100+
101+
static func == (lhs: AppViewModel, rhs: AppViewModel) -> Bool {
102+
lhs.windowId == rhs.windowId && lhs.isFocused == rhs.isFocused
103+
}
72104
}
73105

74106
enum TrayItemType: String, Hashable {
@@ -108,3 +140,21 @@ struct TrayItem: Hashable, Identifiable {
108140
return type.rawValue + name
109141
}
110142
}
143+
144+
@MainActor
145+
private func resolveAppIcon(for window: Window) -> NSImage {
146+
if let macApp = window.app as? MacApp {
147+
if let icon = macApp.nsApp.icon {
148+
return icon
149+
}
150+
if let bundlePath = macApp.bundlePath {
151+
return NSWorkspace.shared.icon(forFile: bundlePath)
152+
}
153+
if let bundleId = macApp.rawAppBundleId,
154+
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId)
155+
{
156+
return NSWorkspace.shared.icon(forFile: url.path)
157+
}
158+
}
159+
return NSImage(named: NSImage.applicationIconName) ?? NSImage()
160+
}

0 commit comments

Comments
 (0)