Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
144 changes: 144 additions & 0 deletions docs/notch-avoidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# MacBook Pro Notch Avoidance Implementation

## Overview

This implementation detects the MacBook Pro notch and adjusts the bar layout to leave a gap in the center, ensuring content doesn't get blocked by the physical notch.

## How It Works

### Backend (Rust/Tauri)

**File:** `src-tauri/src/commands/get_safe_area_insets.rs`

The backend uses macOS APIs to detect the notch through `NSScreen`'s auxiliary areas:

- `auxiliaryTopLeftArea`: The area to the left of the notch
- `auxiliaryTopRightArea`: The area to the right of the notch

The command calculates:
- **Notch width**: `screen_width - left_area_width - right_area_width`
- **Notch height**: The maximum height of the auxiliary areas
- **Has notch**: Whether either auxiliary area has width > 0

This approach is accurate and automatically handles:
- MacBooks without a notch (returns 0 width)
- External monitors (no auxiliary areas)
- Different screen configurations

### Frontend (React/TypeScript)

#### 1. Bar Component (`src/components/Bar.tsx`)

The Bar uses a three-column CSS Grid when a notch gap is present:

- With notch: `grid-template-columns: 1fr [notchWidth] 1fr`
- Left column: `leftOfNotch`
- Middle column: empty notch gap
- Right column: flex container rendering `rightOfNotch` (left-aligned) and `right` (far right)

- Without notch: falls back to the original flex layout with `justify-between`.

Props:
- `leftOfNotch`: JSX for the left side of the notch
- `rightOfNotch`: JSX for the right side of the notch (left-aligned inside right column)
- `center`: `{ width: number }` to reserve the notch gap
- `right`: JSX rendered at the far right (e.g., time widget)

#### 2. AeroSpace Component (`src/widgets/AeroSpace.tsx`)

Enhanced to support automatic splitting by available width:

Props used for splitting:
- `autoSplitSide: 'left' | 'right'`: Enables auto-splitting; component renders its half based on capacity.
- `screenWidth?: number`: Full screen width (from backend).
- `notchWidth?: number`: Reserved notch width (from backend + padding).
- `reservedRightWidth?: number`: Measured width reserved for the `right` content (time widget) on the far right.

Algorithm:
- Compute total content width: `screenWidth - notchWidth`.
- Left capacity: half of total content width.
- Right capacity: half of total content width minus `reservedRightWidth`.
- Split ratio: `leftCapacity / (leftCapacity + rightCapacity)`.
- Split index: `round(totalWorkspaces * splitRatio)`.

Notes:
- The component still supports `maxWorkspaces` and `startIndex`, but the app now uses `autoSplitSide` with capacity-based splitting.

#### 3. App Component (`src/App.tsx`)

On mount, the App:
1. Calls `get_safe_area_insets()` and stores `screenWidth` and `notchWidth` (with +40px padding).
2. Measures the width of the time widget periodically and stores it as `reservedRightWidth`.
3. Renders:
- With notch: two `AeroSpace` instances with `autoSplitSide='left'` and `'right'`, passing `screenWidth`, `notchWidth`, and `reservedRightWidth` to both, plus `center={{ width: notchWidth }}` on the Bar.
- Without notch: original single `AeroSpace` with `right` time widget.

## Visual Result

### With Notch (Split Workspaces Layout)
```
┌─────────────────────────────────────────────────┐
│ [Left by width] ▓▓▓ [Right by width] [Time] │
│ NOTCH │
└─────────────────────────────────────────────────┘
1fr gap 1fr (flex: left + far-right)
```

The layout intelligently splits workspaces by available width, reserving space for the time widget on the far right.

### Without Notch
```
┌─────────────────────────────────────────────────┐
│ [All Workspace Icons] [Time Widget] │
└─────────────────────────────────────────────────┘
```

## Benefits

1. ✅ **Accurate Detection**: Uses official macOS APIs for precise notch dimensions
2. ✅ **Automatic Adaptation**: Works on MacBooks with and without notches
3. ✅ **External Monitor Support**: Defaults to regular layout when no notch
4. ✅ **Flexible Layout**: CSS Grid allows precise control over spacing
5. ✅ **Future-Proof**: Easy to adjust padding or add center content

## Testing

To test the implementation:

1. **On MacBook Pro with notch**:
- Run the app and verify a gap appears in the center
- Check console for notch info logs

2. **On MacBook without notch**:
- Should show original layout with no gap

3. **On external monitor**:
- Should show original layout with no gap

## Configuration

### Adjust Notch Padding

To adjust the padding around the notch, modify this line in `App.tsx`:

```typescript
// Add more or less padding (currently 40px = 20px each side)
setNotchWidth(info.notch_width + 40)
```

### Customize Workspace Split Behavior

You can bias the split left/right by adjusting:
- The padding applied to `notchWidth` (+40px by default)
- The breathing room added to the measured time widget width (+16px by default)

If you prefer fixed counts instead of auto width-based splitting, you can still use `maxWorkspaces` and `startIndex` props on `AeroSpace` (not used by default).

## Future Enhancements

Possible improvements:
- Dynamic padding based on content width
- Option to show something useful in the notch gap (e.g., centered time)
- Responsive adjustments for different screen sizes
- Animation when transitioning between layouts

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-shell = "2"
objc2 = "0.5"
objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication", "NSImage", "NSBitmapImageRep"] }
objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication", "NSImage", "NSBitmapImageRep", "NSScreen"] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSData", "NSArray", "NSDictionary", "NSGeometry"] }
base64 = "0.22"
image = "0.25"
Expand Down
70 changes: 70 additions & 0 deletions src-tauri/src/commands/get_safe_area_insets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use objc2_app_kit::NSScreen;
use objc2_foundation::MainThreadMarker;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct NotchInfo {
pub has_notch: bool,
pub notch_width: f64,
pub notch_height: f64,
pub screen_width: f64,
pub screen_height: f64,
}

#[tauri::command]
pub fn get_safe_area_insets() -> Result<NotchInfo, String> {
// Get the main thread marker (required for Cocoa APIs)
let mtm = unsafe { MainThreadMarker::new_unchecked() };

// Get the main screen
let main_screen = NSScreen::mainScreen(mtm)
.ok_or_else(|| "No main screen found".to_string())?;

// Get the auxiliary areas (notch-adjacent areas)
let left_area = main_screen.auxiliaryTopLeftArea();
let right_area = main_screen.auxiliaryTopRightArea();

// Get the screen frame
let frame = main_screen.frame();

// If either auxiliary area has width, there's a notch
let has_notch = left_area.size.width > 0.0 || right_area.size.width > 0.0;

// Calculate the notch width
// The notch is the gap between the left and right auxiliary areas
let notch_width = if has_notch {
// The notch width is screen width minus the two auxiliary areas
frame.size.width - left_area.size.width - right_area.size.width
} else {
0.0
};

// The notch height is the height of the auxiliary areas
let notch_height = if has_notch {
left_area.size.height.max(right_area.size.height)
} else {
0.0
};

// Debug print only in debug builds
if cfg!(debug_assertions) {
println!(
"[tauri] notch detection → has_notch: {}, notch_width: {:.1}, notch_height: {:.1}, screen: {:.1}x{:.1}, left_area_w: {:.1}, right_area_w: {:.1}",
has_notch,
notch_width,
notch_height,
frame.size.width,
frame.size.height,
left_area.size.width,
right_area.size.width
);
}

Ok(NotchInfo {
has_notch,
notch_width,
notch_height,
screen_width: frame.size.width,
screen_height: frame.size.height,
})
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod get_app_icon;
pub mod get_safe_area_insets;
pub mod set_window_behavior;
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
commands::get_app_icon::get_app_icon,
commands::get_safe_area_insets::get_safe_area_insets,
commands::set_window_behavior::set_window_behavior
])
.setup(|app| {
Expand Down
72 changes: 71 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,82 @@
import './App.css'
import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import AeroSpace from './widgets/AeroSpace'
import { TimeWidget } from './widgets/Time'
import { useRef } from 'react'
import { Bar } from './components/Bar'

interface NotchInfo {
has_notch: boolean
notch_width: number
notch_height: number
screen_width: number
screen_height: number
}

function App() {
const [notchWidth, setNotchWidth] = useState<number>(0)
const [hasNotch, setHasNotch] = useState<boolean>(false)
const [screenWidth, setScreenWidth] = useState<number>(0)
const timeRef = useRef<HTMLDivElement>(null)
const [reservedRightWidth, setReservedRightWidth] = useState<number>(0)

useEffect(() => {
// Fetch notch information on mount
invoke<NotchInfo>('get_safe_area_insets')
.then((info) => {
// dev log removed
if (info.has_notch) {
setHasNotch(true)
// Add some padding (20px on each side) for visual breathing room
const widthWithPadding = info.notch_width + 40
// dev log removed
setNotchWidth(widthWithPadding)
}
setScreenWidth(info.screen_width)
})
.catch((err) => {
console.error('[App] Failed to get notch info:', err)
})
}, [])

useEffect(() => {
const measure = () => {
const el = timeRef.current
if (el) {
const w = el.getBoundingClientRect().width + 16 // small breathing room
setReservedRightWidth(w)
// dev log removed
}
}
measure()
const id = setInterval(measure, 1000)
return () => clearInterval(id)
}, [])

// If we have a notch, split workspaces around it
if (hasNotch) {
// dev log removed
return (
<main className='w-full h-[100vh]'>
<Bar
leftOfNotch={<AeroSpace renameableWorkspace={true} autoSplitSide='left' showEmpty screenWidth={screenWidth} notchWidth={notchWidth} reservedRightWidth={reservedRightWidth} />}
center={{ width: notchWidth }}
rightOfNotch={<AeroSpace renameableWorkspace={true} autoSplitSide='right' showEmpty screenWidth={screenWidth} notchWidth={notchWidth} reservedRightWidth={reservedRightWidth} />}
right={<div ref={timeRef}><TimeWidget /></div>}
/>
</main>
)
}

// No notch - original layout
// dev log removed
return (
<main className='w-full h-[100vh]'>
<Bar left={<AeroSpace renameableWorkspace={true} />} right={<TimeWidget />} />
<Bar
left={<AeroSpace renameableWorkspace={true} />}
right={<TimeWidget />}
/>
</main>
)
}
Expand Down
47 changes: 47 additions & 0 deletions src/components/Bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,58 @@ import clsx from 'clsx'
export const Bar = ({
className,
left,
center,
right,
leftOfNotch,
rightOfNotch,
}: {
className?: string
left?: React.ReactNode
center?: { width: number } | React.ReactNode
right?: React.ReactNode
leftOfNotch?: React.ReactNode
rightOfNotch?: React.ReactNode
}) => {
// If center is provided as an object with width, use that for the notch gap
const notchWidth =
center && typeof center === 'object' && 'width' in center
? center.width
: 0


// If we have a notch width, use CSS Grid for three-column layout
// with optional leftOfNotch and rightOfNotch content
if (notchWidth > 0) {
return (
<div
className={clsx(
'h-[var(--bar-height)]',
'px-2 mt-[6px]',
'grid items-center',
className
)}
style={{
gridTemplateColumns: `1fr ${notchWidth}px 1fr`,
}}
>
{/* Left of notch */}
<div className="flex justify-start">
{leftOfNotch || left}
</div>

{/* Empty space for notch */}
<div />

{/* Right of notch - can contain multiple items */}
<div className="flex justify-between items-center gap-1">
{rightOfNotch && <div className="flex justify-start">{rightOfNotch}</div>}
{right && <div className="flex justify-end ml-auto">{right}</div>}
</div>
</div>
)
}

// Original layout without notch
return (
<div
className={clsx(
Expand All @@ -19,6 +65,7 @@ export const Bar = ({
)}
>
{left && <div>{left}</div>}
{center && typeof center !== 'object' && <div>{center}</div>}
{right && <div>{right}</div>}
</div>
)
Expand Down
Loading