Skip to content

Commit cb4cf33

Browse files
committed
New Experimental UI Settings for menu bar style and functionality
1 parent c7a5910 commit cb4cf33

File tree

3 files changed

+272
-10
lines changed

3 files changed

+272
-10
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import AppKit
2+
import SwiftUI
3+
4+
struct ExperimentalUISettings {
5+
var displayStyle: MenuBarStyle {
6+
get {
7+
if let value = UserDefaults.standard.string(forKey: ExperimentalUISettingsItems.displayStyle.rawValue) {
8+
return MenuBarStyle(rawValue: value) ?? .monospacedText
9+
} else {
10+
return .monospacedText
11+
}
12+
}
13+
set {
14+
UserDefaults.standard.setValue(newValue.rawValue, forKey: ExperimentalUISettingsItems.displayStyle.rawValue)
15+
UserDefaults.standard.synchronize()
16+
}
17+
}
18+
}
19+
20+
enum ExperimentalUISettingsItems: String {
21+
case displayStyle
22+
}
23+
24+
@MainActor
25+
func getExperimentalUISettingsMenu(viewModel: TrayMenuModel) -> some View {
26+
let appearence = NSApplication.shared.effectiveAppearance.name
27+
let color = (appearence == .vibrantDark || appearence == .darkAqua) ? Color.white : Color.black
28+
return Menu {
29+
Text("These settings are EXPERIMENTAL.")
30+
Text("No stability guarantees are provided.")
31+
Divider()
32+
Text("Menu bar style (macOS 14 or later):")
33+
Text(MenuBarStyle.systemText.title)
34+
Button {
35+
viewModel.experimentalUISettings.displayStyle = .systemText
36+
} label: {
37+
Toggle(isOn: .constant(viewModel.experimentalUISettings.displayStyle == .systemText)) {
38+
MenuBarLabel(viewModel.trayText, textStyle: .system, color: color)
39+
}
40+
}
41+
Text(MenuBarStyle.monospacedText.title)
42+
Button {
43+
viewModel.experimentalUISettings.displayStyle = .monospacedText
44+
} label: {
45+
Toggle(isOn: .constant(viewModel.experimentalUISettings.displayStyle == .monospacedText)) {
46+
MenuBarLabel(viewModel.trayText, color: color)
47+
}
48+
}
49+
Text(MenuBarStyle.squares.title)
50+
Button {
51+
viewModel.experimentalUISettings.displayStyle = .squares
52+
} label: {
53+
Toggle(isOn: .constant(viewModel.experimentalUISettings.displayStyle == .squares)) {
54+
MenuBarLabel(viewModel.trayText, color: color, trayItems: viewModel.trayItems)
55+
}
56+
}
57+
Text(MenuBarStyle.i3.title)
58+
Button {
59+
viewModel.experimentalUISettings.displayStyle = .i3
60+
} label: {
61+
Toggle(isOn: .constant(viewModel.experimentalUISettings.displayStyle == .i3)) {
62+
MenuBarLabel(viewModel.trayText, color: color, trayItems: viewModel.trayItems, workspaces: viewModel.workspaces)
63+
}
64+
}
65+
} label: {
66+
Text("Experimental UI Settings")
67+
}
68+
}

Sources/AppBundle/MenuBar.swift

Lines changed: 160 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public func menuBar(viewModel: TrayMenuModel) -> some Scene { // todo should it
2626
}
2727
Divider()
2828
}
29+
getExperimentalUISettingsMenu(viewModel: viewModel)
30+
Divider()
2931
Button(viewModel.isEnabled ? "Disable" : "Enable") {
3032
Task {
3133
try await runSession(.menuBarButton, .forceRun) { () throws in
@@ -62,25 +64,51 @@ public func menuBar(viewModel: TrayMenuModel) -> some Scene { // todo should it
6264
}.keyboardShortcut("Q", modifiers: .command)
6365
} label: {
6466
if viewModel.isEnabled {
65-
MonospacedText(viewModel.trayText)
67+
switch viewModel.experimentalUISettings.displayStyle {
68+
case .systemText:
69+
MenuBarLabel(viewModel.trayText, textStyle: .system)
70+
case .monospacedText:
71+
MenuBarLabel(viewModel.trayText)
72+
case .squares:
73+
MenuBarLabel(viewModel.trayText, trayItems: viewModel.trayItems)
74+
case .i3:
75+
MenuBarLabel(viewModel.trayText, trayItems: viewModel.trayItems, workspaces: viewModel.workspaces)
76+
}
6677
} else {
67-
MonospacedText("⏸️")
78+
MenuBarLabel("⏸️")
6879
}
6980
}
7081
}
7182

72-
struct MonospacedText: View {
83+
@MainActor
84+
struct MenuBarLabel: View {
7385
@Environment(\.colorScheme) var colorScheme: ColorScheme
7486
var text: String
75-
init(_ text: String) { self.text = text }
87+
var textStyle: MenuBarTextStyle
88+
var color: Color?
89+
var trayItems: [TrayItem]?
90+
var workspaces: [WorkspaceViewModel]?
91+
92+
let hStackSpacing = CGFloat(4)
93+
let itemHeight = CGFloat(40)
94+
let itemBorderSize = CGFloat(4)
95+
let itemCornerRadius = CGFloat(6)
96+
97+
var finalColor: Color {
98+
return color ?? (colorScheme == .dark ? Color.white : Color.black)
99+
}
100+
101+
init(_ text: String, textStyle: MenuBarTextStyle = .monospaced, color: Color? = nil, trayItems: [TrayItem]? = nil, workspaces: [WorkspaceViewModel]? = nil) {
102+
self.text = text
103+
self.textStyle = textStyle
104+
self.color = color
105+
self.trayItems = trayItems
106+
self.workspaces = workspaces
107+
}
76108

77109
var body: some View {
78110
if #available(macOS 14, *) { // https://github.com/nikitabobko/AeroSpace/issues/1122
79-
let renderer = ImageRenderer(
80-
content: Text(text)
81-
.font(.system(.largeTitle, design: .monospaced))
82-
.foregroundStyle(colorScheme == .light ? Color.black : Color.white)
83-
)
111+
let renderer = ImageRenderer(content: menuBarContent)
84112
if let cgImage = renderer.cgImage {
85113
// Using scale: 1 results in a blurry image for unknown reasons
86114
Image(cgImage, scale: 2, label: Text(text))
@@ -92,6 +120,129 @@ struct MonospacedText: View {
92120
Text(text)
93121
}
94122
}
123+
124+
var menuBarContent: some View {
125+
return ZStack {
126+
if let trayItems {
127+
HStack(spacing: hStackSpacing) {
128+
ForEach(trayItems, id: \.id) { item in
129+
itemView(for: item)
130+
if item.type == .mode {
131+
Text(":")
132+
.font(.system(.largeTitle, design: textStyle.design))
133+
.foregroundStyle(finalColor)
134+
.bold()
135+
}
136+
}
137+
if workspaces != nil {
138+
let otherWorkspaces = Workspace.all.filter { workspace in
139+
!workspace.isEffectivelyEmpty && !trayItems.contains(where: { item in item.type == .monitor && item.name == workspace.name })
140+
}
141+
if !otherWorkspaces.isEmpty {
142+
Group {
143+
Text("|")
144+
.font(.system(.largeTitle, design: textStyle.design))
145+
.foregroundStyle(finalColor)
146+
.bold()
147+
.padding(.bottom, 2)
148+
ForEach(otherWorkspaces, id: \.name) { item in
149+
itemView(for: TrayItem(type: .monitor, name: item.name, isActive: false))
150+
}
151+
}
152+
.opacity(0.6)
153+
}
154+
}
155+
}
156+
.frame(height: itemHeight)
157+
} else {
158+
HStack(spacing: hStackSpacing) {
159+
Text(text)
160+
.font(.system(.largeTitle, design: textStyle.design))
161+
.foregroundStyle(finalColor)
162+
}
163+
}
164+
}
165+
}
166+
167+
@ViewBuilder
168+
fileprivate func itemView(for item: TrayItem) -> some View {
169+
if item.name.containsEmoji() {
170+
// If workspace name contains emojis we use the plain emoji in text to avoid visibility issues scaling the emoji to fit the squares
171+
Text(item.name)
172+
.font(.system(.largeTitle, design: textStyle.design))
173+
.foregroundStyle(finalColor)
174+
} else {
175+
if let imageName = item.systemImageName {
176+
Image(systemName: imageName)
177+
.resizable()
178+
.aspectRatio(contentMode: .fit)
179+
.symbolRenderingMode(.monochrome)
180+
.foregroundStyle(finalColor)
181+
} else {
182+
let text = Text(item.name)
183+
.font(.system(.largeTitle, design: textStyle.design))
184+
.bold()
185+
.padding(.horizontal, item.isActive ? itemBorderSize * 2 : itemBorderSize * 1.5)
186+
.frame(height: itemHeight)
187+
if item.isActive {
188+
ZStack {
189+
text.foregroundStyle(.clear)
190+
.overlay(
191+
RoundedRectangle(cornerRadius: itemCornerRadius, style: .circular)
192+
)
193+
text.blendMode(.destinationOut)
194+
}
195+
.compositingGroup()
196+
.foregroundStyle(finalColor)
197+
} else {
198+
text
199+
.padding(.horizontal, itemBorderSize)
200+
.overlay(
201+
RoundedRectangle(cornerRadius: itemCornerRadius, style: .circular)
202+
.strokeBorder(lineWidth: itemBorderSize)
203+
)
204+
.foregroundStyle(finalColor)
205+
}
206+
}
207+
}
208+
}
209+
}
210+
211+
enum MenuBarTextStyle: String {
212+
case monospaced
213+
case system
214+
var design: Font.Design {
215+
switch self {
216+
case .monospaced:
217+
return .monospaced
218+
case .system:
219+
return .default
220+
}
221+
}
222+
}
223+
224+
enum MenuBarStyle: String, CaseIterable, Identifiable, Equatable, Hashable {
225+
case monospacedText
226+
case systemText
227+
case squares
228+
case i3
229+
var id: Int {
230+
return self.hashValue
231+
}
232+
var title: String {
233+
switch self {
234+
case .monospacedText: "Monospaced font"
235+
case .systemText: "System font"
236+
case .squares: "Square images"
237+
case .i3: "i3 style"
238+
}
239+
}
240+
}
241+
242+
private extension String {
243+
func containsEmoji() -> Bool {
244+
unicodeScalars.contains { $0.properties.isEmoji && $0.properties.isEmojiPresentation }
245+
}
95246
}
96247

97248
func getTextEditorToOpenConfig() -> URL {

Sources/AppBundle/TrayMenuModel.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ public class TrayMenuModel: ObservableObject {
77
private init() {}
88

99
@Published var trayText: String = ""
10+
@Published var trayItems: [TrayItem] = []
1011
/// Is "layouting" enabled
1112
@Published var isEnabled: Bool = true
1213
@Published var workspaces: [WorkspaceViewModel] = []
14+
@Published var experimentalUISettings: ExperimentalUISettings = ExperimentalUISettings()
1315
}
1416

1517
@MainActor func updateTrayText() {
@@ -25,10 +27,51 @@ public class TrayMenuModel: ObservableObject {
2527
let monitor = $0.isVisible || !$0.isEffectivelyEmpty ? " - \($0.workspaceMonitor.name)" : ""
2628
return WorkspaceViewModel(name: $0.name, suffix: monitor, isFocused: focus.workspace == $0)
2729
}
30+
var items = sortedMonitors.map {
31+
TrayItem(type: .monitor, name: $0.activeWorkspace.name, isActive: $0.activeWorkspace == focus.workspace && sortedMonitors.count > 1)
32+
}
33+
let mode = activeMode?.takeIf { $0 != mainModeId }?.first?.lets { TrayItem(type: .mode, name: $0.uppercased(), isActive: true) }
34+
if let mode {
35+
items.insert(mode, at: 0)
36+
}
37+
TrayMenuModel.shared.trayItems = items
2838
}
2939

30-
struct WorkspaceViewModel {
40+
struct WorkspaceViewModel: Hashable {
3141
let name: String
3242
let suffix: String
3343
let isFocused: Bool
3444
}
45+
46+
enum TrayItemType: String, Hashable {
47+
case mode
48+
case monitor
49+
}
50+
51+
fileprivate let validNumbers = "0" ... "9"
52+
fileprivate let validLetters = "A" ... "Z"
53+
54+
struct TrayItem: Hashable, Identifiable {
55+
let type: TrayItemType
56+
let name: String
57+
let isActive: Bool
58+
var systemImageName: String? {
59+
// System image type is only valid for single number and single capital char workspace name
60+
guard name.count == 1 else { return nil }
61+
guard validNumbers.contains(name) || validLetters.contains(name) else { return nil }
62+
let lowercasedName = name.lowercased()
63+
switch type {
64+
case .mode:
65+
return "\(lowercasedName).circle"
66+
case .monitor:
67+
if isActive {
68+
return "\(lowercasedName).square.fill"
69+
} else {
70+
return "\(lowercasedName).square"
71+
}
72+
}
73+
}
74+
var id: String {
75+
return type.rawValue + name
76+
}
77+
}

0 commit comments

Comments
 (0)