Files
tolaria/src/components/LinuxTitlebar.tsx

240 lines
7.3 KiB
TypeScript
Raw Normal View History

2026-04-24 16:25:36 +02:00
import type { CSSProperties, MouseEvent, ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useDragRegion } from '../hooks/useDragRegion'
2026-05-30 04:19:51 +02:00
import { resolveEffectiveLocale, translate, type AppLocale } from '../lib/i18n'
2026-05-23 23:36:22 +02:00
import { shouldUseCustomWindowChrome } from '../utils/platform'
import { cleanupTauriEventListener } from '../utils/tauriEventCleanup'
2026-04-24 16:25:36 +02:00
import { LinuxMenuButton } from './LinuxMenuButton'
import { Button } from './ui/button'
export const LINUX_TITLEBAR_HEIGHT = 32
const RESIZE_EDGE = 6
type ResizeDirection =
| 'East'
| 'North'
| 'NorthEast'
| 'NorthWest'
| 'South'
| 'SouthEast'
| 'SouthWest'
| 'West'
2026-05-30 04:19:51 +02:00
type LinuxTitlebarProps = {
locale?: AppLocale
}
2026-04-24 16:25:36 +02:00
const RESIZE_HANDLES: ReadonlyArray<{
cursor: CSSProperties['cursor']
direction: ResizeDirection
style: CSSProperties
}> = [
{ direction: 'North', cursor: 'ns-resize', style: { top: 0, left: RESIZE_EDGE, right: RESIZE_EDGE, height: RESIZE_EDGE } },
{ direction: 'South', cursor: 'ns-resize', style: { bottom: 0, left: RESIZE_EDGE, right: RESIZE_EDGE, height: RESIZE_EDGE } },
{ direction: 'West', cursor: 'ew-resize', style: { top: RESIZE_EDGE, bottom: RESIZE_EDGE, left: 0, width: RESIZE_EDGE } },
{ direction: 'East', cursor: 'ew-resize', style: { top: RESIZE_EDGE, bottom: RESIZE_EDGE, right: 0, width: RESIZE_EDGE } },
{ direction: 'NorthWest', cursor: 'nwse-resize', style: { top: 0, left: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } },
{ direction: 'NorthEast', cursor: 'nesw-resize', style: { top: 0, right: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } },
{ direction: 'SouthWest', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } },
{ direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } },
]
2026-05-30 04:19:51 +02:00
export function LinuxTitlebar({ locale: localeOverride }: LinuxTitlebarProps = {}) {
2026-05-23 23:36:22 +02:00
const customChromeEnabled = shouldUseCustomWindowChrome()
2026-05-30 04:19:51 +02:00
const [documentLocale, setDocumentLocale] = useState(readDocumentLocale)
const locale = localeOverride ?? documentLocale
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
2026-05-23 23:36:22 +02:00
const maximized = useLinuxMaximizedState(customChromeEnabled)
2026-04-24 16:25:36 +02:00
2026-05-30 04:19:51 +02:00
useEffect(() => {
if (localeOverride || !customChromeEnabled || typeof document === 'undefined') return
const syncLocale = () => setDocumentLocale(readDocumentLocale())
syncLocale()
const observer = new MutationObserver(syncLocale)
observer.observe(document.documentElement, { attributeFilter: ['lang'], attributes: true })
return () => observer.disconnect()
}, [customChromeEnabled, localeOverride])
2026-05-23 23:36:22 +02:00
if (!customChromeEnabled) return null
2026-04-24 16:25:36 +02:00
const appWindow = getCurrentWindow()
return (
<>
<ResizeHandles />
<div
ref={dragRegionRef}
2026-04-24 16:25:36 +02:00
className="fixed top-0 right-0 left-0 z-[1000] flex items-center justify-between border-b border-border bg-background select-none"
style={{ height: LINUX_TITLEBAR_HEIGHT }}
data-testid="linux-titlebar"
>
<div className="flex h-full items-center" data-no-drag>
<LinuxMenuButton locale={locale} />
2026-04-24 16:25:36 +02:00
</div>
2026-05-30 04:19:51 +02:00
<TitlebarWindowControls appWindow={appWindow} locale={locale} maximized={maximized} />
2026-04-24 16:25:36 +02:00
</div>
</>
)
}
2026-05-30 04:19:51 +02:00
function readDocumentLocale(): AppLocale {
if (typeof document === 'undefined') return 'en'
return resolveEffectiveLocale(document.documentElement.lang)
}
2026-04-24 16:25:36 +02:00
function useLinuxMaximizedState(enabled: boolean): boolean {
const [maximized, setMaximized] = useState(false)
useEffect(() => {
if (!enabled) return
const appWindow = getCurrentWindow()
let active = true
const syncMaximizeState = () => {
void appWindow.isMaximized().then((value) => {
if (active) setMaximized(value)
}).catch(() => {})
}
syncMaximizeState()
const unlistenPromise = appWindow.onResized(syncMaximizeState)
return () => {
active = false
void unlistenPromise.then(cleanupTauriEventListener).catch(() => {})
2026-04-24 16:25:36 +02:00
}
}, [enabled])
return maximized
}
function ResizeHandles() {
2026-05-23 23:36:22 +02:00
if (!shouldUseCustomWindowChrome()) return null
2026-04-24 16:25:36 +02:00
const startResize = (direction: ResizeDirection) => (event: MouseEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
void getCurrentWindow().startResizeDragging(direction).catch(() => {})
}
return (
<>
{RESIZE_HANDLES.map(({ cursor, direction, style }) => (
<div
key={direction}
aria-hidden
className="fixed z-[1001]"
data-no-drag
onMouseDown={startResize(direction)}
style={{ ...style, cursor }}
/>
))}
</>
)
}
function TitlebarWindowControls({
appWindow,
2026-05-30 04:19:51 +02:00
locale,
2026-04-24 16:25:36 +02:00
maximized,
}: {
appWindow: ReturnType<typeof getCurrentWindow>
2026-05-30 04:19:51 +02:00
locale: AppLocale
2026-04-24 16:25:36 +02:00
maximized: boolean
}) {
2026-05-30 04:19:51 +02:00
const minimizeLabel = translate(locale, 'window.minimize')
const resizeLabel = translate(locale, maximized ? 'window.restore' : 'window.maximize')
const closeLabel = translate(locale, 'window.close')
2026-04-24 16:25:36 +02:00
return (
<div className="flex h-full items-center" data-no-drag>
2026-05-30 04:19:51 +02:00
<TitlebarButton ariaLabel={minimizeLabel} onClick={() => void appWindow.minimize().catch(() => {})}>
2026-04-24 16:25:36 +02:00
<MinimizeIcon />
</TitlebarButton>
<TitlebarButton
2026-05-30 04:19:51 +02:00
ariaLabel={resizeLabel}
2026-04-24 16:25:36 +02:00
onClick={() => void appWindow.toggleMaximize().catch(() => {})}
>
{maximized ? <RestoreIcon /> : <MaximizeIcon />}
</TitlebarButton>
<TitlebarButton
2026-05-30 04:19:51 +02:00
ariaLabel={closeLabel}
2026-04-24 16:25:36 +02:00
close
onClick={() => void appWindow.close().catch(() => {})}
>
<CloseIcon />
</TitlebarButton>
</div>
)
}
function TitlebarButton({
ariaLabel,
children,
close = false,
onClick,
}: {
ariaLabel: string
children: ReactNode
close?: boolean
onClick: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={ariaLabel}
className={[
'h-full w-[46px] rounded-none text-foreground/70 hover:text-foreground',
2026-04-24 22:26:07 +02:00
close ? 'hover:bg-destructive hover:text-destructive-foreground' : 'hover:bg-foreground/10',
2026-04-24 16:25:36 +02:00
].join(' ')}
onClick={onClick}
data-no-drag
>
{children}
</Button>
)
}
function MinimizeIcon() {
return (
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round">
2026-04-24 16:25:36 +02:00
<line x1="2.5" y1="6" x2="9.5" y2="6" />
</svg>
)
}
function MaximizeIcon() {
return (
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
2026-04-24 16:25:36 +02:00
<rect x="2.5" y="2.5" width="7" height="7" rx="0.5" />
</svg>
)
}
function RestoreIcon() {
return (
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
2026-04-24 16:25:36 +02:00
<rect x="2.5" y="3.8" width="6" height="6" rx="0.5" />
<path d="M4 3.8 V 2.5 H 9.5 V 8" />
</svg>
)
}
function CloseIcon() {
return (
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round">
2026-04-24 16:25:36 +02:00
<line x1="3" y1="3" x2="9" y2="9" />
<line x1="9" y1="3" x2="3" y2="9" />
</svg>
)
}