Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add accordion icons indicator overlay
  • Loading branch information
frecano committed Apr 1, 2026
commit cd21b21eef566535d2d3c5a244ca0aeacd5240a9
2 changes: 1 addition & 1 deletion Sources/AppBundle/command/impl/FocusCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ struct FocusCommand: Command {
case .direction(let direction):
let window = target.windowOrNil
if let (parent, ownIndex) = window?.closestParent(hasChildrenInDirection: direction, withLayout: nil) {
guard let windowToFocus = parent.children[ownIndex + direction.focusOffset]
guard let windowToFocus = parent.children[ownIndex + direction.accordionFocusOffset(parent)]
.findLeafWindowRecursive(snappedTo: direction.opposite) else { return false }
return windowToFocus.focusWindow()
} else {
Expand Down
4 changes: 2 additions & 2 deletions Sources/AppBundle/command/impl/MoveCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ struct MoveCommand: Command {
switch parent.cases {
case .tilingContainer(let parent):
let indexOfCurrent = currentWindow.ownIndex.orDie()
let indexOfSiblingTarget = indexOfCurrent + direction.focusOffset
if parent.orientation == direction.orientation && parent.children.indices.contains(indexOfSiblingTarget) {
let indexOfSiblingTarget = indexOfCurrent + direction.accordionFocusOffset(parent)
if parent.matchesDirection(direction) && parent.children.indices.contains(indexOfSiblingTarget) {
switch parent.children[indexOfSiblingTarget].tilingTreeNodeCasesOrDie() {
case .tilingContainer(let topLevelSiblingTargetContainer):
return deepMoveIn(window: currentWindow, into: topLevelSiblingTargetContainer, moveDirection: direction)
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppBundle/command/impl/SwapCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct SwapCommand: Command {
switch args.target.val {
case .direction(let direction):
if let (parent, ownIndex) = currentWindow.closestParent(hasChildrenInDirection: direction, withLayout: nil) {
targetWindow = parent.children[ownIndex + direction.focusOffset].findLeafWindowRecursive(snappedTo: direction.opposite)
targetWindow = parent.children[ownIndex + direction.accordionFocusOffset(parent)].findLeafWindowRecursive(snappedTo: direction.opposite)
} else if args.wrapAround {
targetWindow = target.workspace.findLeafWindowRecursive(snappedTo: direction.opposite)
} else {
Expand Down
1 change: 1 addition & 0 deletions Sources/AppBundle/config/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct Config: ConvenienceCopyable {
var autoReloadConfig: Bool = false
var automaticallyUnhideMacosHiddenApps: Bool = false
var accordionPadding: Int = 30
var accordionIndicator: AccordionIndicatorConfig = AccordionIndicatorConfig()
var enableNormalizationOppositeOrientationForNestedContainers: Bool = true
var persistentWorkspaces: OrderedSet<String> = []
var execOnWorkspaceChange: [String] = [] // todo deprecate
Expand Down
1 change: 1 addition & 0 deletions Sources/AppBundle/config/parseConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ private let configParser: [String: any ParserProtocol<Config>] = [
"auto-reload-config": Parser(\.autoReloadConfig, parseBool),
"automatically-unhide-macos-hidden-apps": Parser(\.automaticallyUnhideMacosHiddenApps, parseBool),
"accordion-padding": Parser(\.accordionPadding, parseInt),
"accordion-indicator": Parser(\.accordionIndicator, parseAccordionIndicator),
persistentWorkspacesKey: Parser(\.persistentWorkspaces, parsePersistentWorkspaces),
"exec-on-workspace-change": Parser(\.execOnWorkspaceChange, parseArrayOfStrings),
"exec": Parser(\.execConfig, parseExecConfig),
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppBundle/layout/refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ enum OptimalHideCorner {
@MainActor
private func layoutWorkspaces() async throws {
if !TrayMenuModel.shared.isEnabled {
AccordionIndicatorManager.shared.hideAll()
for workspace in Workspace.all {
workspace.allLeafWindowsRecursive.forEach { ($0 as! MacWindow).unhideFromCorner() } // todo as!
try await workspace.layoutWorkspace() // Unhide tiling windows from corner
Expand Down Expand Up @@ -187,6 +188,7 @@ private func layoutWorkspaces() async throws {
try await (window as! MacWindow).hideInCorner(corner) // todo as!
}
}
AccordionIndicatorManager.shared.refresh()
}

@MainActor
Expand Down
26 changes: 26 additions & 0 deletions Sources/AppBundle/tree/TilingContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,32 @@ enum Layout: String {
case accordion
}

extension TilingContainer {
/// Whether a direction matches this container for navigation purposes.
/// When `accordion-indicator.vertical-navigation` is enabled, accordion containers
/// accept up/down directions regardless of their actual orientation.
@MainActor
func matchesDirection(_ direction: CardinalDirection) -> Bool {
if config.accordionIndicator.accordionVerticalNavigation && layout == .accordion && direction.orientation == .v {
return true
}
return orientation == direction.orientation
}
}

extension CardinalDirection {
/// Returns the focus offset for navigating within a container.
/// When vertical navigation is enabled for accordion, up/down map to previous/next.
@MainActor
func accordionFocusOffset(_ container: TilingContainer) -> Int {
if config.accordionIndicator.accordionVerticalNavigation && container.layout == .accordion && orientation == .v {
// up = previous (-1), down = next (+1)
return self == .up ? -1 : 1
}
return focusOffset
}
}

extension String {
func parseLayout() -> Layout? {
if let parsed = Layout(rawValue: self) {
Expand Down
6 changes: 3 additions & 3 deletions Sources/AppBundle/tree/TreeNodeEx.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ extension TreeNode {
}

/// Returns closest parent that has children in the specified direction relative to `self`
@MainActor
func closestParent(
hasChildrenInDirection direction: CardinalDirection,
withLayout layout: Layout?,
Expand All @@ -91,14 +92,13 @@ extension TreeNode {
true
case .tilingContainer(let parent):
(layout == nil || parent.layout == layout) &&
parent.orientation == direction.orientation &&
(node.ownIndex.map { parent.children.indices.contains($0 + direction.focusOffset) } ?? true)
parent.matchesDirection(direction) &&
(node.ownIndex.map { parent.children.indices.contains($0 + direction.accordionFocusOffset(parent)) } ?? true)
}
})
guard let innermostChild else { return nil }
switch innermostChild.parent?.cases {
case .tilingContainer(let parent):
check(parent.orientation == direction.orientation)
return innermostChild.ownIndex.map { (parent, $0) }
case .workspace, nil, .macosMinimizedWindowsContainer,
.macosFullscreenWindowsContainer, .macosHiddenAppsWindowsContainer, .macosPopupWindowsContainer:
Expand Down
264 changes: 264 additions & 0 deletions Sources/AppBundle/ui/AccordionIndicator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import AppKit
import Common
import SwiftUI

/// Manages overlay panels that show app icons for windows in accordion containers
@MainActor
final class AccordionIndicatorManager {
static let shared = AccordionIndicatorManager()

/// Pool of reusable panels keyed by a stable identifier
private var panels: [ObjectIdentifier: AccordionIndicatorPanel] = [:]

private init() {}

func refresh() {
guard config.accordionIndicator.enabled else {
hideAll()
return
}

var activeContainerIds: Set<ObjectIdentifier> = []

for workspace in Workspace.all where workspace.isVisible {
collectAccordionContainers(workspace.rootTilingContainer, into: &activeContainerIds)
}

// Remove panels for containers that no longer exist or aren't visible
for (id, panel) in panels where !activeContainerIds.contains(id) {
panel.close()
panels.removeValue(forKey: id)
}
}

private func collectAccordionContainers(_ node: TreeNode, into ids: inout Set<ObjectIdentifier>) {
if let container = node as? TilingContainer {
if container.layout == .accordion && container.children.count > 1 {
let id = ObjectIdentifier(container)
ids.insert(id)
updatePanel(for: container, id: id)
}
for child in container.children {
collectAccordionContainers(child, into: &ids)
}
}
}

private func updatePanel(for container: TilingContainer, id: ObjectIdentifier) {
guard let rect = container.lastAppliedLayoutPhysicalRect else { return }

let windows = container.children.compactMap { $0 as? Window }
guard !windows.isEmpty else { return }

let mruWindow = container.mostRecentChild as? Window

let entries: [AccordionIndicatorEntry] = windows.map { window in
let icon: NSImage
if let macWindow = window as? MacWindow {
icon = macWindow.macApp.nsApp.icon ?? NSImage(named: NSImage.applicationIconName)!
} else {
icon = NSImage(named: NSImage.applicationIconName)!
}
return AccordionIndicatorEntry(
windowId: window.windowId,
icon: icon,
isFocused: window === mruWindow
)
}

let panel: AccordionIndicatorPanel
if let existing = panels[id] {
panel = existing
} else {
panel = AccordionIndicatorPanel()
panels[id] = panel
}

let indicatorConfig = config.accordionIndicator
let position = indicatorConfig.position
let iconSize = CGFloat(indicatorConfig.iconSize)
let iconPadding = CGFloat(indicatorConfig.iconPadding)
let panelPadding = CGFloat(indicatorConfig.barPadding)

let totalIcons = CGFloat(entries.count)
let isVerticalBar: Bool // The indicator bar orientation (icons stacked vertically or horizontally)

let panelWidth: CGFloat
let panelHeight: CGFloat
let panelX: CGFloat
let panelY: CGFloat

let margin: CGFloat = 4 // gap between indicator and window edge

switch position {
case .left, .right:
isVerticalBar = true
panelWidth = iconSize + panelPadding * 2
panelHeight = totalIcons * (iconSize + iconPadding) - iconPadding + panelPadding * 2
panelY = screenFlipY(rect.topLeftY, height: panelHeight)
panelX = position == .left
? rect.topLeftX - panelWidth - margin
: rect.topLeftX + rect.width + margin
case .top, .bottom:
isVerticalBar = false
panelWidth = totalIcons * (iconSize + iconPadding) - iconPadding + panelPadding * 2
panelHeight = iconSize + panelPadding * 2
panelX = rect.topLeftX
panelY = position == .top
? screenFlipY(rect.topLeftY - panelHeight - margin, height: panelHeight)
: screenFlipY(rect.topLeftY + rect.height + margin, height: panelHeight)
}

let model = AccordionIndicatorModel(
entries: entries,
isVertical: isVerticalBar,
iconSize: iconSize,
iconPadding: iconPadding,
barPadding: panelPadding,
onIconClick: { windowId in
Task { @MainActor in
if let window = Window.get(byId: windowId) {
_ = window.focusWindow()
window.nativeFocus()
scheduleRefreshSession(.menuBarButton)
}
}
}
)
let hostingView = NSHostingView(rootView: AccordionIndicatorView(model: model))
hostingView.frame = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight)

panel.contentView?.subviews.removeAll()
panel.contentView?.addSubview(hostingView)
panel.setFrame(NSRect(x: panelX, y: panelY, width: panelWidth, height: panelHeight), display: true)
panel.orderFrontRegardless()
}

func hideAll() {
for (_, panel) in panels {
panel.close()
}
panels.removeAll()
}

/// Convert AeroSpace top-left Y coordinate to macOS bottom-left Y coordinate
private func screenFlipY(_ topLeftY: CGFloat, height: CGFloat) -> CGFloat {
guard let screen = NSScreen.main else { return topLeftY }
return screen.frame.height - topLeftY - height
}
}

// MARK: - Panel

final class AccordionIndicatorPanel: NSPanelHud {
override init() {
super.init()
self.hasShadow = true
self.isOpaque = false
self.backgroundColor = .clear
self.ignoresMouseEvents = false
self.canHide = false
self.styleMask.insert(.nonactivatingPanel)
// Prevent the panel from ever becoming key or main
self.becomesKeyOnlyIfNeeded = true
}

override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}

// MARK: - Data Model

struct AccordionIndicatorEntry: Identifiable {
let windowId: UInt32
let icon: NSImage
let isFocused: Bool
var id: UInt32 { windowId }
}

struct AccordionIndicatorModel {
let entries: [AccordionIndicatorEntry]
let isVertical: Bool
let iconSize: CGFloat
let iconPadding: CGFloat
let barPadding: CGFloat
let onIconClick: (UInt32) -> Void
}

// MARK: - SwiftUI View

struct AccordionIndicatorView: View {
let model: AccordionIndicatorModel

var body: some View {
Group {
if model.isVertical {
VStack(spacing: model.iconPadding) {
iconViews
}
} else {
HStack(spacing: model.iconPadding) {
iconViews
}
}
}
.padding(model.barPadding)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}

@ViewBuilder
private var iconViews: some View {
ForEach(model.entries) { entry in
Image(nsImage: entry.icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: model.iconSize, height: model.iconSize)
.opacity(entry.isFocused ? 1.0 : 0.4)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(entry.isFocused ? Color.accentColor : Color.clear, lineWidth: 2)
)
.onTapGesture {
model.onIconClick(entry.windowId)
}
}
}
}

// MARK: - Config

struct AccordionIndicatorConfig: ConvenienceCopyable, Equatable {
var enabled: Bool = false
var iconSize: Int = 30
var iconPadding: Int = 2
var barPadding: Int = 4
var position: AccordionIndicatorPosition = .left
var accordionVerticalNavigation: Bool = false
}

enum AccordionIndicatorPosition: String, Equatable {
case left, right, top, bottom
}

// MARK: - Config Parsing

private let accordionIndicatorParser: [String: any ParserProtocol<AccordionIndicatorConfig>] = [
"enabled": Parser(\.enabled, parseBool),
"icon-size": Parser(\.iconSize, parseInt),
"icon-padding": Parser(\.iconPadding, parseInt),
"bar-padding": Parser(\.barPadding, parseInt),
"position": Parser(\.position, parseAccordionIndicatorPosition),
"vertical-navigation": Parser(\.accordionVerticalNavigation, parseBool),
]

func parseAccordionIndicator(_ raw: Json, _ backtrace: ConfigBacktrace, _ errors: inout [ConfigParseError]) -> AccordionIndicatorConfig {
parseTable(raw, AccordionIndicatorConfig(), accordionIndicatorParser, backtrace, &errors)
}

private func parseAccordionIndicatorPosition(_ raw: Json, _ backtrace: ConfigBacktrace) -> ParsedConfig<AccordionIndicatorPosition> {
parseString(raw, backtrace).flatMap {
AccordionIndicatorPosition(rawValue: $0)
.orFailure(.semantic(backtrace, "Can't parse accordion indicator position '\($0)'. Expected: left, right, top, bottom"))
}
}