Merge pull request #2498 from kydorn/feat/linux-window-controls-style
feat(desktop): gate traffic-lights behind window-controls style setting
This commit is contained in:
@@ -5,6 +5,71 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getDesktopWindowControlsOrder, invokeDesktop } from '@/lib/desktop';
|
||||
import type { DesktopWindowControlAction, DesktopWindowControlsSide } from '@/lib/desktop';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
// macOS chrome colors; intentionally theme-independent — these replicate a
|
||||
// foreign platform's chrome, not OpenChamber's own status tokens, so the
|
||||
// theme-system hex rule does not apply.
|
||||
const TRAFFIC_LIGHT_FILL: Record<DesktopWindowControlAction, string> = {
|
||||
close: '#FF5F57',
|
||||
minimize: '#FEBC2E',
|
||||
maximize: '#28C940',
|
||||
};
|
||||
// Glyphs are a uniform translucent black, matching macOS — opacity lets the
|
||||
// fill tint the symbol so it reads on all three fills without per-action colors.
|
||||
const TRAFFIC_LIGHT_GLYPH = 'rgba(0, 0, 0, 0.7)';
|
||||
|
||||
const TrafficLightGlyph: React.FC<{ action: DesktopWindowControlAction }> = ({ action }) => {
|
||||
if (action === 'close') {
|
||||
return <Icon name="close" className="size-[10px]" />;
|
||||
}
|
||||
if (action === 'minimize') {
|
||||
return <Icon name="subtract" className="size-[10px]" />;
|
||||
}
|
||||
// Green maximize/restore: same `add` (+) glyph for both states. The label
|
||||
// (restore vs maximize) still flips via isMaximized in TrafficLightButton.
|
||||
return <Icon name="add" className="size-[10px]" />;
|
||||
};
|
||||
|
||||
type TrafficLightButtonProps = {
|
||||
action: DesktopWindowControlAction;
|
||||
isMaximized: boolean;
|
||||
onActivate: (action: DesktopWindowControlAction) => void;
|
||||
};
|
||||
|
||||
const TrafficLightButton: React.FC<TrafficLightButtonProps> = ({ action, isMaximized, onActivate }) => {
|
||||
const { t } = useI18n();
|
||||
const fill = TRAFFIC_LIGHT_FILL[action];
|
||||
const label =
|
||||
action === 'close'
|
||||
? t('header.windowControls.close')
|
||||
: action === 'minimize'
|
||||
? t('header.windowControls.minimize')
|
||||
: isMaximized
|
||||
? t('header.windowControls.restore')
|
||||
: t('header.windowControls.maximize');
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(action)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
// 24px-wide button wrapping a 14px circle centers it at a 24px interval
|
||||
// between neighbors, giving a 10px edge-to-edge gap. The 32px height
|
||||
// keeps the titlebar's vertical hit band.
|
||||
className="app-region-no-drag flex h-8 w-[24px] items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span
|
||||
className="flex size-3.5 items-center justify-center rounded-full shadow-[inset_0_0_0_0.5px_rgba(0,0,0,0.28)] transition-[filter] duration-75 active:brightness-90"
|
||||
style={{ backgroundColor: fill, color: TRAFFIC_LIGHT_GLYPH }}
|
||||
>
|
||||
<span className="flex items-center justify-center opacity-0 transition-opacity duration-75 group-hover/wctl:opacity-100">
|
||||
<TrafficLightGlyph action={action} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type WindowsWindowControlsProps = {
|
||||
visible: boolean;
|
||||
@@ -17,6 +82,7 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
|
||||
}: WindowsWindowControlsProps) {
|
||||
const { t } = useI18n();
|
||||
const [isMaximized, setIsMaximized] = React.useState(false);
|
||||
const desktopWindowControlsStyle = useUIStore((state) => state.desktopWindowControlsStyle);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
@@ -48,12 +114,50 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Left-side controls sit in the same cluster as h-8 titlebar icon buttons
|
||||
// (app menu / sidebar / project actions). Match that size and avoid negative
|
||||
// margins so TitlebarLeftControls can publish an accurate reserved width —
|
||||
// otherwise the project-actions chevron overlaps the session title.
|
||||
// Right-side controls keep a taller Windows-style hit target.
|
||||
const isLeft = position === 'left';
|
||||
// Order is side-driven for both styles: close, minimize, maximize on the
|
||||
// left; minimize, maximize, close on the right.
|
||||
const order = getDesktopWindowControlsOrder(position);
|
||||
|
||||
const activate = (action: DesktopWindowControlAction) => {
|
||||
if (action === 'close') {
|
||||
void invokeDesktop('desktop_close_current_window');
|
||||
return;
|
||||
}
|
||||
if (action === 'minimize') {
|
||||
void invokeDesktop('desktop_minimize_current_window');
|
||||
return;
|
||||
}
|
||||
void invokeDesktop<{ maximized?: boolean }>('desktop_toggle_current_window_maximized')
|
||||
.then((state) => setIsMaximized(Boolean(state?.maximized)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// Traffic-light chrome: macOS-style 14px circles in an h-8 band. Glyphs
|
||||
// reveal on any-cluster hover (group-hover/wctl). TitlebarLeftControls
|
||||
// measures and republishes the cluster width via ResizeObserver, so the
|
||||
// narrower footprint takes effect without touching reserved-width constants.
|
||||
if (desktopWindowControlsStyle === 'traffic-lights') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'app-region-no-drag group/wctl flex h-8 shrink-0 items-center',
|
||||
isLeft ? 'mr-1' : 'ml-1',
|
||||
)}
|
||||
aria-label={t('header.windowControls.groupAria')}
|
||||
>
|
||||
{order.map((action) => (
|
||||
<TrafficLightButton key={action} action={action} isMaximized={isMaximized} onActivate={activate} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Classic Windows-style square buttons. Left side matches the h-8 titlebar
|
||||
// icon cluster (app menu / sidebar / project actions) and avoids negative
|
||||
// margins so TitlebarLeftControls publishes an accurate reserved width —
|
||||
// otherwise the project-actions chevron overlaps the session title. Right
|
||||
// side keeps a taller h-12 Windows-style hit target.
|
||||
const buttonClassName = cn(
|
||||
'app-region-no-drag inline-flex items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
isLeft ? 'h-8 w-8 rounded-md' : 'h-12 w-11',
|
||||
@@ -113,7 +217,7 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
|
||||
|
||||
return (
|
||||
<div className={containerClassName} aria-label={t('header.windowControls.groupAria')}>
|
||||
{getDesktopWindowControlsOrder(position).map(renderControl)}
|
||||
{order.map(renderControl)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isWebRuntime,
|
||||
usesFramelessElectronChrome,
|
||||
type DesktopWindowControlsPosition,
|
||||
type DesktopWindowControlsStyle,
|
||||
} from '@/lib/desktop';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { usePwaDetection } from '@/hooks/usePwaDetection';
|
||||
@@ -285,6 +286,11 @@ const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPositio
|
||||
{ id: 'right', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsRight' },
|
||||
];
|
||||
|
||||
const WINDOW_CONTROLS_STYLE_OPTIONS: Array<{ id: DesktopWindowControlsStyle; labelKey: string }> = [
|
||||
{ id: 'classic', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsClassic' },
|
||||
{ id: 'traffic-lights', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights' },
|
||||
];
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
visibleSettings?: VisibleSetting[];
|
||||
@@ -425,6 +431,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const showWindowControlsPosition = usesFramelessElectronChrome();
|
||||
const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition);
|
||||
const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition);
|
||||
const desktopWindowControlsStyle = useUIStore((state) => state.desktopWindowControlsStyle);
|
||||
const setDesktopWindowControlsStyle = useUIStore((state) => state.setDesktopWindowControlsStyle);
|
||||
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
|
||||
const reportUsage = useUIStore(state => state.reportUsage);
|
||||
const setReportUsage = useUIStore(state => state.setReportUsage);
|
||||
@@ -440,6 +448,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void updateDesktopSettings({ desktopWindowControlsPosition: value });
|
||||
}, [setDesktopWindowControlsPosition]);
|
||||
|
||||
const handleWindowControlsStyleChange = React.useCallback((value: DesktopWindowControlsStyle) => {
|
||||
setDesktopWindowControlsStyle(value);
|
||||
void updateDesktopSettings({ desktopWindowControlsStyle: value });
|
||||
}, [setDesktopWindowControlsStyle]);
|
||||
|
||||
const shouldAnimateChatPreview = (isSettingsDialogOpen || isMobile || isVSCodeRuntime())
|
||||
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
|
||||
|
||||
@@ -1010,20 +1023,40 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
{showWindowControlsPositionSetting && (
|
||||
<SettingsSection
|
||||
title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}
|
||||
description={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
|
||||
title={t('settings.openchamber.desktopNetwork.field.windowControls')}
|
||||
info={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
|
||||
divider={hasThemeSettings}
|
||||
settingsItem="sessions.desktop-window-controls-position"
|
||||
>
|
||||
<SettingsChipGroup
|
||||
value={desktopWindowControlsPosition}
|
||||
options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
|
||||
value: option.id,
|
||||
label: tUnsafe(option.labelKey),
|
||||
}))}
|
||||
onChange={handleWindowControlsPositionChange}
|
||||
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
|
||||
/>
|
||||
<SettingsTwoColumn>
|
||||
<SettingsStackedField
|
||||
label={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}
|
||||
settingsItem="sessions.desktop-window-controls-position"
|
||||
>
|
||||
<SettingsChipGroup
|
||||
value={desktopWindowControlsPosition}
|
||||
options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
|
||||
value: option.id,
|
||||
label: tUnsafe(option.labelKey),
|
||||
}))}
|
||||
onChange={handleWindowControlsPositionChange}
|
||||
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
<SettingsStackedField
|
||||
label={t('settings.openchamber.desktopNetwork.field.windowControlsStyle')}
|
||||
settingsItem="sessions.desktop-window-controls-style"
|
||||
>
|
||||
<SettingsChipGroup
|
||||
value={desktopWindowControlsStyle}
|
||||
options={WINDOW_CONTROLS_STYLE_OPTIONS.map((option) => ({
|
||||
value: option.id,
|
||||
label: tUnsafe(option.labelKey),
|
||||
}))}
|
||||
onChange={handleWindowControlsStyleChange}
|
||||
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsStyleAria')}
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
</SettingsTwoColumn>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user