diff --git a/docs/notch-avoidance.md b/docs/notch-avoidance.md new file mode 100644 index 0000000..a5052d8 --- /dev/null +++ b/docs/notch-avoidance.md @@ -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 + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1b4ff53..585262f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/get_safe_area_insets.rs b/src-tauri/src/commands/get_safe_area_insets.rs new file mode 100644 index 0000000..9fb10de --- /dev/null +++ b/src-tauri/src/commands/get_safe_area_insets.rs @@ -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 { + // 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, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6944cf5..4bfcf65 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod get_app_icon; +pub mod get_safe_area_insets; pub mod set_window_behavior; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e141019..2fe4f67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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| { diff --git a/src/App.tsx b/src/App.tsx index cd7afab..9f0ee6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(0) + const [hasNotch, setHasNotch] = useState(false) + const [screenWidth, setScreenWidth] = useState(0) + const timeRef = useRef(null) + const [reservedRightWidth, setReservedRightWidth] = useState(0) + + useEffect(() => { + // Fetch notch information on mount + invoke('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 ( +
+ } + center={{ width: notchWidth }} + rightOfNotch={} + right={
} + /> +
+ ) + } + + // No notch - original layout + // dev log removed return (
- } right={} /> + } + right={} + />
) } diff --git a/src/components/Bar.tsx b/src/components/Bar.tsx index b97f4df..4411a05 100644 --- a/src/components/Bar.tsx +++ b/src/components/Bar.tsx @@ -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 ( +
+ {/* Left of notch */} +
+ {leftOfNotch || left} +
+ + {/* Empty space for notch */} +
+ + {/* Right of notch - can contain multiple items */} +
+ {rightOfNotch &&
{rightOfNotch}
} + {right &&
{right}
} +
+
+ ) + } + + // Original layout without notch return (
{left &&
{left}
} + {center && typeof center !== 'object' &&
{center}
} {right &&
{right}
}
) diff --git a/src/widgets/AeroSpace.tsx b/src/widgets/AeroSpace.tsx index 9db9d45..51b3619 100644 --- a/src/widgets/AeroSpace.tsx +++ b/src/widgets/AeroSpace.tsx @@ -14,16 +14,7 @@ type ASWindow = { 'window-title': string } -type ASMonitor = { - 'monitor-id': number - 'monitor-name': string -} - -type ASApp = { - 'app-bundle-0id': string - 'app-name': string - 'app-pid': number -} +// Removed unused types for cleanliness async function aeroSpaceQuery(query: string): Promise { let result = await Command.create('exec-sh', [ @@ -133,7 +124,7 @@ const WorkspaceLabel = ({ id, renameableWorkspace }: { id: string; renameableWor ) } -function Workspace({ id, isFocused, renameableWorkspace }: { id: string; isFocused: boolean; renameableWorkspace: boolean }) { +function Workspace({ id, isFocused, renameableWorkspace, showEmpty }: { id: string; isFocused: boolean; renameableWorkspace: boolean; showEmpty?: boolean }) { const [windows, setWindows] = useState([]) useEffect(() => { @@ -148,28 +139,63 @@ function Workspace({ id, isFocused, renameableWorkspace }: { id: string; isFocus const hasWindows = windows.length > 0 - // only show space if there's window - if (!hasWindows) { - return null - } - return ( - + - + {hasWindows && } ) } -export default function AeroSpace({ renameableWorkspace }: { renameableWorkspace: boolean }) { +export default function AeroSpace({ + renameableWorkspace, + maxWorkspaces, + startIndex = 0, + showEmpty = false, + autoSplitSide, + screenWidth, + notchWidth, + reservedRightWidth, +}: { + renameableWorkspace: boolean + maxWorkspaces?: number + startIndex?: number + showEmpty?: boolean + autoSplitSide?: 'left' | 'right' + screenWidth?: number + notchWidth?: number + reservedRightWidth?: number +}) { const [focusedWorkspace, workspaces] = useWorkspaces() + // Determine which workspaces to show + let displayWorkspaces: ASWorkspace[] + if (autoSplitSide) { + const n = workspaces.length + // Base available width excluding notch + const totalBarContentWidth = Math.max(0, (screenWidth || 0) - (notchWidth || 0)) + const halfWidth = totalBarContentWidth > 0 ? totalBarContentWidth / 2 : 0 + // Right side loses the reserved area for the time widget + const rightCapacity = Math.max(0, halfWidth - (reservedRightWidth || 0)) + const leftCapacity = halfWidth + const denom = leftCapacity + rightCapacity + const leftShare = denom > 0 ? (leftCapacity / denom) : 0.5 + const splitIndex = Math.min(n, Math.max(0, Math.round(n * leftShare))) + displayWorkspaces = autoSplitSide === 'left' ? workspaces.slice(0, splitIndex) : workspaces.slice(splitIndex) + } else { + displayWorkspaces = maxWorkspaces + ? workspaces.slice(startIndex, startIndex + maxWorkspaces) + : workspaces.slice(startIndex) + } + + // dev logs removed + return (
- {workspaces.map((workspace) => { + {displayWorkspaces.map((workspace) => { const isFocused = workspace.workspace === focusedWorkspace return ( - + ) })}