diff --git a/Sources/AppBundle/MenuBar.swift b/Sources/AppBundle/MenuBar.swift index 479749f0..e5795f04 100644 --- a/Sources/AppBundle/MenuBar.swift +++ b/Sources/AppBundle/MenuBar.swift @@ -52,24 +52,34 @@ public func menuBar(viewModel: TrayMenuModel) -> some Scene { }.keyboardShortcut("Q", modifiers: .command) } label: { if viewModel.isEnabled { - MonospacedText(viewModel.trayText) + switch config.menuBarStyle { + case .text: + MenuBarLabel(viewModel.trayText) + case .squares: + MenuBarLabel(viewModel.trayText, trayItems: viewModel.trayItems) + case .i3: + MenuBarLabel(viewModel.trayText, trayItems: viewModel.trayItems, workspaces: viewModel.workspaces) + } } else { - MonospacedText("⏸️") + MenuBarLabel("⏸️") } } } -struct MonospacedText: View { +struct MenuBarLabel: View { @Environment(\.colorScheme) var colorScheme: ColorScheme var text: String - init(_ text: String) { self.text = text } + var trayItems: [TrayItem]? + var workspaces: [WorkspaceViewModel]? + + init(_ text: String, trayItems: [TrayItem]? = nil, workspaces: [WorkspaceViewModel]? = nil) { + self.text = text + self.trayItems = trayItems + self.workspaces = workspaces + } var body: some View { - let renderer = ImageRenderer( - content: Text(text) - .font(.system(.largeTitle, design: .monospaced)) - .foregroundStyle(colorScheme == .light ? Color.black : Color.white) - ) + let renderer = ImageRenderer(content: menuBarContent) if let cgImage = renderer.cgImage { // Using scale: 1 results in a blurry image for unknown reasons Image(cgImage, scale: 2, label: Text(text)) @@ -78,6 +88,87 @@ struct MonospacedText: View { Text(text) } } + + @ViewBuilder + var menuBarContent: some View { + let color = colorScheme == .light ? Color.black : Color.white + if let trayItems { + HStack(spacing: 4) { + ForEach(trayItems, id: \.name) { item in + if item.name.containsEmoji() { + Text(item.name) + .font(.system(.largeTitle, design: .monospaced)) + .foregroundStyle(color) + .bold() + } else { + Image(systemName: item.systemImageName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(color) + if item.type == .mode { + Text(":") + .font(.system(.largeTitle, design: .monospaced)) + .foregroundStyle(color) + .bold() + } + } + } + if workspaces != nil { + let otherWorkspaces = Workspace.all.filter { workspace in + !workspace.isEffectivelyEmpty && !trayItems.contains(where: { item in item.name == workspace.name }) + } + if !otherWorkspaces.isEmpty { + Group { + Text("|") + .font(.system(.largeTitle, design: .monospaced)) + .foregroundStyle(color) + .bold() + .padding(.bottom, 2) + ForEach(otherWorkspaces, id: \.name) { item in + if item.name.containsEmoji() { + Text(item.name) + .font(.system(.largeTitle, design: .monospaced)) + .foregroundStyle(color) + .bold() + } else { + Image(systemName: "\(item.name.lowercased()).square") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(color) + } + } + } + .opacity(0.6) + } + } + } + .frame(height: 40) + } else { + Text(text) + .font(.system(.largeTitle, design: .monospaced)) + .foregroundStyle(colorScheme == .light ? Color.black : Color.white) + } + } +} + +enum MenuBarStyle: String { + case text = "text" + case squares = "squares" + case i3 = "i3" +} + +extension String { + func parseMenuBarStyle() -> MenuBarStyle? { + if let parsed = MenuBarStyle(rawValue: self) { + return parsed + } else { + return nil + } + } + + func containsEmoji() -> Bool { + unicodeScalars.contains { $0.properties.isEmoji && $0.properties.isEmojiPresentation } + } } func getTextEditorToOpenConfig() -> URL { diff --git a/Sources/AppBundle/TrayMenuModel.swift b/Sources/AppBundle/TrayMenuModel.swift index e551ab94..afd6ac11 100644 --- a/Sources/AppBundle/TrayMenuModel.swift +++ b/Sources/AppBundle/TrayMenuModel.swift @@ -7,6 +7,7 @@ public class TrayMenuModel: ObservableObject { private init() {} @Published var trayText: String = "" + @Published var trayItems: [TrayItem] = [] /// Is "layouting" enabled @Published var isEnabled: Bool = true @Published var workspaces: [WorkspaceViewModel] = [] @@ -15,20 +16,51 @@ public class TrayMenuModel: ObservableObject { func updateTrayText() { let sortedMonitors = sortedMonitors let focus = focus - TrayMenuModel.shared.trayText = (activeMode?.takeIf { $0 != mainModeId }?.first?.lets { "[\($0.uppercased())] " } ?? "") + - sortedMonitors + var items: [TrayItem] = [] + TrayMenuModel.shared.trayText = ( + activeMode?.takeIf { $0 != mainModeId }?.first?.lets { + items.append(TrayItem(type: .mode ,name: String($0), isActive: true)) + return "[\($0.uppercased())] " } ?? "" + ) + + sortedMonitors .map { - ($0.activeWorkspace == focus.workspace && sortedMonitors.count > 1 ? "*" : "") + $0.activeWorkspace.name + items.append(TrayItem(type: .monitor, name: $0.activeWorkspace.name, isActive: $0.activeWorkspace == focus.workspace && sortedMonitors.count > 1)) + return ($0.activeWorkspace == focus.workspace && sortedMonitors.count > 1 ? "*" : "") + $0.activeWorkspace.name } .joined(separator: " │ ") TrayMenuModel.shared.workspaces = Workspace.all.map { let monitor = $0.isVisible || !$0.isEffectivelyEmpty ? " - \($0.workspaceMonitor.name)" : "" return WorkspaceViewModel(name: $0.name, suffix: monitor, isFocused: focus.workspace == $0) } + TrayMenuModel.shared.trayItems = items } -struct WorkspaceViewModel { +struct WorkspaceViewModel: Hashable { let name: String let suffix: String let isFocused: Bool } + +enum TrayItemType: String { + case mode + case monitor +} + +struct TrayItem: Hashable { + let type: TrayItemType + let name: String + let isActive: Bool + var systemImageName: String { + let lowercasedName = name.lowercased() + switch type { + case .mode: + return "\(lowercasedName).circle" + case .monitor: + if isActive { + return "\(lowercasedName).square.fill" + } else { + return "\(lowercasedName).square" + } + } + } +} diff --git a/Sources/AppBundle/config/Config.swift b/Sources/AppBundle/config/Config.swift index 2c0aad91..58091962 100644 --- a/Sources/AppBundle/config/Config.swift +++ b/Sources/AppBundle/config/Config.swift @@ -36,6 +36,7 @@ struct Config: Copyable { var _indentForNestedContainersWithTheSameOrientation: Void = () var enableNormalizationFlattenContainers: Bool = true var _nonEmptyWorkspacesRootContainersLayoutOnStartup: Void = () + var menuBarStyle: MenuBarStyle = .text var defaultRootContainerLayout: Layout = .tiles var defaultRootContainerOrientation: DefaultContainerOrientation = .auto var startAtLogin: Bool = false diff --git a/Sources/AppBundle/config/parseConfig.swift b/Sources/AppBundle/config/parseConfig.swift index bca2b5d6..12f8c7f0 100644 --- a/Sources/AppBundle/config/parseConfig.swift +++ b/Sources/AppBundle/config/parseConfig.swift @@ -97,6 +97,8 @@ private let configParser: [String: any ParserProtocol] = [ "enable-normalization-flatten-containers": Parser(\.enableNormalizationFlattenContainers, parseBool), "enable-normalization-opposite-orientation-for-nested-containers": Parser(\.enableNormalizationOppositeOrientationForNestedContainers, parseBool), + "menu-bar-style": Parser(\.menuBarStyle, parseMenuBarStyle), + "default-root-container-layout": Parser(\.defaultRootContainerLayout, parseLayout), "default-root-container-orientation": Parser(\.defaultRootContainerOrientation, parseDefaultContainerOrientation), @@ -275,6 +277,11 @@ private func parseLayout(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace .flatMap { $0.parseLayout().orFailure(.semantic(backtrace, "Can't parse layout '\($0)'")) } } +private func parseMenuBarStyle(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml { + parseString(raw, backtrace) + .flatMap { $0.parseMenuBarStyle().orFailure(.semantic(backtrace, "Can't parse menu bar style '\($0)'")) } +} + private func skipParsing(_ value: T) -> (_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> ParsedToml { { _, _ in .success(value) } } diff --git a/docs/config-examples/default-config.toml b/docs/config-examples/default-config.toml index 4ea7f9ee..f8ccca71 100644 --- a/docs/config-examples/default-config.toml +++ b/docs/config-examples/default-config.toml @@ -26,6 +26,14 @@ accordion-padding = 30 # Possible values: tiles|accordion default-root-container-layout = 'tiles' +# Possible values: text|squares|i3 +# - text: displays menu bar as monospaced text (only active workspaces for each monitor). +# - squares: displays menu bar as square with the workspace name inside. Filled square is the +# active monitor, bordered square for other monitors (displays only active workspaces +# for each monitor). +# - i3: same as squares + shows all other workspaces that contains windows in it, like i3. +menu-bar-style = 'text' + # Possible values: horizontal|vertical|auto # 'auto' means: wide monitor (anything wider than high) gets horizontal orientation, # tall monitor (anything higher than wide) gets vertical orientation