diff --git a/app/app.tsx b/app/app.tsx
index 17bdb65..20c6898 100644
--- a/app/app.tsx
+++ b/app/app.tsx
@@ -9,7 +9,7 @@ import { connectToServer } from "./state/connectToServer"
import { useTheme, themed } from "./theme/theme"
import { useEffect, useMemo } from "react"
import { TimelineScreen } from "./screens/TimelineScreen"
-import { useMenuItem } from "./utils/useMenuItem"
+import { useSystemMenu } from "./utils/useSystemMenu/useSystemMenu"
import { Titlebar } from "./components/Titlebar/Titlebar"
import { Sidebar } from "./components/Sidebar/Sidebar"
import { useSidebar } from "./state/useSidebar"
@@ -74,12 +74,12 @@ function App(): React.JSX.Element {
},
...(__DEV__
? [
- {
- label: "Toggle Dev Menu",
- shortcut: "cmd+shift+d",
- action: () => NativeModules.DevMenu.show(),
- },
- ]
+ {
+ label: "Toggle Dev Menu",
+ shortcut: "cmd+shift+d",
+ action: () => NativeModules.DevMenu.show(),
+ },
+ ]
: []),
],
Window: [
@@ -101,7 +101,7 @@ function App(): React.JSX.Element {
[toggleSidebar],
)
- useMenuItem(menuConfig)
+ useSystemMenu(menuConfig)
setTimeout(() => {
fetch("https://www.google.com")
diff --git a/app/components/Menu/MenuDropdown.tsx b/app/components/Menu/MenuDropdown.tsx
new file mode 100644
index 0000000..19e998a
--- /dev/null
+++ b/app/components/Menu/MenuDropdown.tsx
@@ -0,0 +1,105 @@
+import { View, type ViewStyle, type TextStyle } from "react-native"
+import { useRef, useMemo, memo } from "react"
+import { themed } from "../../theme/theme"
+import { Portal } from "../Portal"
+import { MenuDropdownItem } from "./MenuDropdownItem"
+import { useSubmenuState } from "./useSubmenuState"
+import { menuSettings } from "./menuSettings"
+import { type Position, type DropdownMenuItem, type MenuItem, MENU_SEPARATOR } from "./types"
+import { getUUID } from "../../utils/random/getUUID"
+import { Separator } from "../Separator"
+
+interface MenuDropdownProps {
+ items: (DropdownMenuItem | typeof MENU_SEPARATOR)[]
+ position: Position
+ onItemPress: (item: MenuItem) => void
+ isSubmenu?: boolean
+}
+
+const MenuDropdownComponent = ({
+ items,
+ position,
+ onItemPress,
+ isSubmenu,
+}: MenuDropdownProps) => {
+ const portalName = useRef(
+ `${isSubmenu ? 'submenu' : 'dropdown'}-${getUUID()}`
+ ).current
+ const { openSubmenu, submenuPosition, handleItemHover } = useSubmenuState(position)
+
+ const isSeparator = (item: MenuItem | typeof MENU_SEPARATOR): item is typeof MENU_SEPARATOR => {
+ return item === MENU_SEPARATOR
+ }
+
+ // Find the submenu item if one is open
+ const submenuItem = openSubmenu
+ ? items.find(item => !isSeparator(item) && item.label === openSubmenu) as DropdownMenuItem | undefined
+ : undefined
+
+ const dropdownContent = useMemo(() => (
+
+ {items.map((item, index) => {
+ if (isSeparator(item)) return
+
+ return (
+
+ )
+ })}
+
+ ), [items, isSubmenu, position.x, position.y, onItemPress, handleItemHover])
+
+ return (
+ <>
+
+ {dropdownContent}
+
+ {/* Render submenu */}
+ {submenuItem?.submenu && (
+
+ )}
+ >
+ )
+}
+
+export const MenuDropdown = memo(MenuDropdownComponent)
+
+const $dropdown = themed(({ colors, spacing }) => ({
+ position: "absolute",
+ backgroundColor: colors.cardBackground,
+ borderColor: colors.keyline,
+ borderWidth: 1,
+ borderRadius: 4,
+ minWidth: menuSettings.dropdownMinWidth,
+ paddingVertical: spacing.xs,
+ zIndex: menuSettings.zIndex.dropdown,
+}))
+
+const $submenuDropdown = themed(({ colors, spacing }) => ({
+ position: "absolute",
+ backgroundColor: colors.cardBackground,
+ borderColor: colors.keyline,
+ borderWidth: 1,
+ borderRadius: 4,
+ minWidth: menuSettings.submenuMinWidth,
+ paddingVertical: spacing.xs,
+ zIndex: menuSettings.zIndex.submenu,
+}))
+
+
diff --git a/app/components/Menu/MenuDropdownItem.tsx b/app/components/Menu/MenuDropdownItem.tsx
new file mode 100644
index 0000000..8628f1f
--- /dev/null
+++ b/app/components/Menu/MenuDropdownItem.tsx
@@ -0,0 +1,145 @@
+import { Pressable, Text, View, type ViewStyle, type TextStyle } from "react-native"
+import { useState, useRef, memo, useCallback } from "react"
+import { themed } from "../../theme/theme"
+import { menuSettings } from "./menuSettings"
+import type { MenuItem } from "./types"
+
+interface MenuDropdownItemProps {
+ item: MenuItem
+ index: number
+ onItemPress: (item: MenuItem) => void
+ onItemHover: (itemLabel: string, index: number, hasSubmenu: boolean) => void
+}
+
+const MenuDropdownItemComponent = ({
+ item,
+ index,
+ onItemPress,
+ onItemHover,
+}: MenuDropdownItemProps) => {
+ const [hoveredItem, setHoveredItem] = useState(null)
+ const hoverTimeoutRef = useRef(null)
+ const enabled = item.enabled !== false
+
+ const handleHoverIn = useCallback(() => {
+ // Clear any pending hover clear
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current)
+ hoverTimeoutRef.current = null
+ }
+ setHoveredItem(item.label)
+ const hasSubmenu = !!item.submenu
+ onItemHover(item.label, index, hasSubmenu)
+ }, [item.label, item.submenu, index, onItemHover])
+
+ const handleHoverOut = useCallback(() => {
+ // Use a small timeout to prevent flickering between items
+ hoverTimeoutRef.current = setTimeout(() => {
+ setHoveredItem((current) => current === item.label ? null : current)
+ }, 10)
+ }, [item.label])
+
+ const handlePress = useCallback(() => {
+ if (!item.action || !enabled) return
+ item.action()
+ onItemPress(item)
+ }, [item, onItemPress])
+
+ return (
+ [
+ $dropdownItem(),
+ ((pressed || hoveredItem === item.label) && enabled) && $dropdownItemHovered(),
+ !enabled && $dropdownItemDisabled,
+ ]}
+ >
+
+ {item.label}
+
+
+ {item.shortcut && (
+
+ {formatShortcut(item.shortcut)}
+
+ )}
+ {item.submenu && (
+
+ ▶
+
+ )}
+
+
+ )
+}
+
+export const MenuDropdownItem = memo(MenuDropdownItemComponent)
+
+function formatShortcut(shortcut: string): string {
+ return shortcut
+ .replace(/cmd/gi, "Ctrl")
+ .replace(/shift/gi, "Shift")
+ .replace(/\+/g, "+")
+ .split("+")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join("+")
+}
+
+const $dropdownItem = themed(({ spacing }) => ({
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs,
+ borderRadius: 4,
+ minHeight: menuSettings.itemMinHeight,
+}))
+
+const $dropdownItemHovered = themed(({ colors }) => ({
+ backgroundColor: colors.neutralVery,
+}))
+
+const $dropdownItemDisabled = {
+ opacity: 0.5,
+}
+
+const $dropdownItemText = themed(({ colors, typography }) => ({
+ color: colors.mainText,
+ fontSize: typography.caption,
+}))
+
+const $dropdownItemTextDisabled = themed((theme) => ({
+ color: theme.colors.neutral,
+}))
+
+const $shortcut = themed(({ colors, typography, spacing }) => ({
+ color: colors.neutral,
+ fontSize: typography.small,
+ marginLeft: spacing.md,
+}))
+
+const $submenuArrow = themed(({ colors, typography, spacing }) => ({
+ color: colors.neutral,
+ fontSize: typography.small,
+ marginLeft: spacing.sm,
+}))
+
+const $rightContent: ViewStyle = {
+ flexDirection: "row",
+ alignItems: "center",
+}
\ No newline at end of file
diff --git a/app/components/Menu/MenuOverlay.tsx b/app/components/Menu/MenuOverlay.tsx
new file mode 100644
index 0000000..65e6768
--- /dev/null
+++ b/app/components/Menu/MenuOverlay.tsx
@@ -0,0 +1,44 @@
+import { Pressable, type ViewStyle } from "react-native"
+import { Portal } from "../Portal"
+import { menuSettings } from "./menuSettings"
+
+interface MenuOverlayProps {
+ onPress: () => void
+ portalName?: string
+ style?: ViewStyle
+ excludeArea?: {
+ top?: number
+ left?: number
+ right?: number
+ bottom?: number
+ }
+}
+
+export const MenuOverlay = ({
+ onPress,
+ portalName = 'menu-overlay',
+ style,
+ excludeArea,
+}: MenuOverlayProps) => {
+
+ return (
+
+
+
+ )
+}
+
+interface OverlayStyleArgs {
+ excludeArea?: { top?: number, left?: number, right?: number, bottom?: number }
+ style?: ViewStyle
+}
+
+const overlayStyle: (args: OverlayStyleArgs) => ViewStyle = ({ excludeArea, style }: OverlayStyleArgs) => ({
+ position: "absolute",
+ top: excludeArea?.top ?? 0,
+ left: excludeArea?.left ?? 0,
+ right: excludeArea?.right ?? 0,
+ bottom: excludeArea?.bottom ?? 0,
+ zIndex: menuSettings.zIndex.menuOverlay,
+ ...style,
+})
\ No newline at end of file
diff --git a/app/components/Menu/menuSettings.ts b/app/components/Menu/menuSettings.ts
new file mode 100644
index 0000000..79a034f
--- /dev/null
+++ b/app/components/Menu/menuSettings.ts
@@ -0,0 +1,13 @@
+export const menuSettings = {
+ dropdownMinWidth: 200,
+ submenuMinWidth: 150,
+ itemMinHeight: 28,
+ itemHeight: 32,
+ submenuOffsetX: 200,
+ submenuOffsetY: -5,
+ zIndex: {
+ menuOverlay: 9999,
+ dropdown: 10000,
+ submenu: 10001,
+ }
+} as const
\ No newline at end of file
diff --git a/app/components/Menu/types.ts b/app/components/Menu/types.ts
new file mode 100644
index 0000000..486ef78
--- /dev/null
+++ b/app/components/Menu/types.ts
@@ -0,0 +1,19 @@
+export interface Position {
+ x: number
+ y: number
+}
+
+// Generic menu item interface for UI components
+export interface MenuItem {
+ label: string
+ shortcut?: string
+ enabled?: boolean
+ action?: () => void
+ submenu?: (MenuItem | typeof MENU_SEPARATOR)[]
+}
+
+// Type alias for dropdown menu items (same as MenuItem)
+export type DropdownMenuItem = MenuItem
+
+// Menu separator constant
+export const MENU_SEPARATOR = 'menu-item-separator' as const
\ No newline at end of file
diff --git a/app/components/Menu/useMenuPositioning.ts b/app/components/Menu/useMenuPositioning.ts
new file mode 100644
index 0000000..cac99d6
--- /dev/null
+++ b/app/components/Menu/useMenuPositioning.ts
@@ -0,0 +1,50 @@
+import { useCallback } from "react"
+import { menuSettings } from "./menuSettings"
+import type { Position } from "./types"
+
+export interface PositioningStrategy {
+ calculateSubmenuPosition: (
+ basePosition: Position,
+ itemIndex: number,
+ parentWidth?: number
+ ) => Position
+ calculateContextMenuPosition?: (
+ clickPosition: Position,
+ menuSize?: { width: number; height: number },
+ screenSize?: { width: number; height: number }
+ ) => Position
+}
+
+const defaultStrategy: PositioningStrategy = {
+ calculateSubmenuPosition: (basePosition, itemIndex, parentWidth = menuSettings.submenuOffsetX) => ({
+ x: basePosition.x + parentWidth,
+ y: basePosition.y + itemIndex * menuSettings.itemHeight + menuSettings.submenuOffsetY,
+ }),
+
+ calculateContextMenuPosition: (clickPosition, menuSize, screenSize) => {
+ // Basic positioning - can be enhanced for screen edge detection
+ return {
+ x: clickPosition.x,
+ y: clickPosition.y,
+ }
+ },
+}
+
+export const useMenuPositioning = (strategy: PositioningStrategy = defaultStrategy) => {
+ const calculateSubmenuPosition = useCallback(
+ (basePosition: Position, itemIndex: number, parentWidth?: number) =>
+ strategy.calculateSubmenuPosition(basePosition, itemIndex, parentWidth),
+ [strategy]
+ )
+
+ const calculateContextMenuPosition = useCallback(
+ (clickPosition: Position, menuSize?: { width: number; height: number }, screenSize?: { width: number; height: number }) =>
+ strategy.calculateContextMenuPosition?.(clickPosition, menuSize, screenSize) ?? clickPosition,
+ [strategy]
+ )
+
+ return {
+ calculateSubmenuPosition,
+ calculateContextMenuPosition,
+ }
+}
\ No newline at end of file
diff --git a/app/components/Menu/useSubmenuState.ts b/app/components/Menu/useSubmenuState.ts
new file mode 100644
index 0000000..1c105ca
--- /dev/null
+++ b/app/components/Menu/useSubmenuState.ts
@@ -0,0 +1,37 @@
+import { useState, useCallback } from "react"
+import { menuSettings } from "./menuSettings"
+import { type Position } from "./types"
+
+export const useSubmenuState = (basePosition: Position) => {
+ const [openSubmenu, setOpenSubmenu] = useState(null)
+ const [submenuPosition, setSubmenuPosition] = useState({ x: 0, y: 0 })
+
+ const openSubmenuAt = useCallback((itemLabel: string, index: number) => {
+ setOpenSubmenu(itemLabel)
+ setSubmenuPosition({
+ x: basePosition.x + menuSettings.submenuOffsetX,
+ y: basePosition.y + index * menuSettings.itemHeight + menuSettings.submenuOffsetY,
+ })
+ }, [basePosition.x, basePosition.y])
+
+ const closeSubmenu = useCallback(() => {
+ setOpenSubmenu(null)
+ }, [])
+
+ const handleItemHover = useCallback((itemLabel: string, index: number, hasSubmenu: boolean) => {
+ if (hasSubmenu) {
+ openSubmenuAt(itemLabel, index)
+ } else {
+ if (openSubmenu) {
+ closeSubmenu()
+ }
+ }
+ }, [openSubmenu, openSubmenuAt, closeSubmenu])
+
+ return {
+ openSubmenu,
+ submenuPosition,
+ handleItemHover,
+ closeSubmenu,
+ }
+}
\ No newline at end of file
diff --git a/app/components/Titlebar/Titlebar.tsx b/app/components/Titlebar/Titlebar.tsx
index c939596..6c4e029 100644
--- a/app/components/Titlebar/Titlebar.tsx
+++ b/app/components/Titlebar/Titlebar.tsx
@@ -4,6 +4,7 @@ import { Icon } from "../Icon"
import ActionButton from "../ActionButton"
import { useSidebar } from "../../state/useSidebar"
import { PassthroughView } from "./PassthroughView"
+import { TitlebarMenu } from "./TitlebarMenu"
export const Titlebar = () => {
const theme = useTheme()
@@ -13,6 +14,11 @@ export const Titlebar = () => {
+ {Platform.OS === "windows" && (
+
+
+
+ )}
(
diff --git a/app/components/Titlebar/TitlebarMenu.tsx b/app/components/Titlebar/TitlebarMenu.tsx
new file mode 100644
index 0000000..60b48ce
--- /dev/null
+++ b/app/components/Titlebar/TitlebarMenu.tsx
@@ -0,0 +1,81 @@
+import { View, ViewStyle } from "react-native"
+import { useState, useCallback, useRef } from "react"
+import { themed } from "../../theme/theme"
+import { TitlebarMenuItem } from "./TitlebarMenuItem"
+import { MenuDropdown } from "../Menu/MenuDropdown"
+import { MenuOverlay } from "../Menu/MenuOverlay"
+import type { Position } from "../Menu/types"
+import { PassthroughView } from "./PassthroughView"
+import { useSystemMenu } from "../../utils/useSystemMenu/useSystemMenu"
+
+export const TitlebarMenu = () => {
+ const { menuStructure, menuItems, handleMenuItemPressed } = useSystemMenu()
+ const [openMenu, setOpenMenu] = useState(null)
+ const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 })
+ const menuRefs = useRef