Merge pull request #419 from Open-Less/fix/windows-ui-417-418

fix(ui): Windows 原生 chrome / 胶囊缩小 / 自定义下拉 (#417 #418)
This commit is contained in:
TRIP authored and GitHub committed 2026-05-12 14:54:37 +08:00
commit 993bb90dd3
16 files changed
+460 -424

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openless-app",
"version": "1.3.0",
"version": "1.3.1-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openless-app",
"version": "1.3.0",
"version": "1.3.1-1",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
@@ -21,14 +21,6 @@ import {
type LatestBetaRelease,
type UpdateChannel,
} from '../lib/ipc';
import {
FOLLOW_SYSTEM,
getLocalePreference,
outputPrefsForLocale,
setLocalePreference,
type SupportedLocale,
} from '../i18n';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import type { OS } from './WindowChrome';
interface SettingsModalProps {
@@ -264,9 +256,6 @@ function PersonalizeSection() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row label={t('modal.personalize.language')}>
<LanguagePicker />
</Row>
<Row label={t('modal.personalize.font')} desc={t('modal.personalize.fontDesc')}>
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
{fontOptions.map(([id, label]) => {
@@ -533,46 +522,3 @@ const btnGhost: CSSProperties = {
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
};
// 真正可用的语言切换器 —— 用原生 <select>,与 Settings → Language 分区共享同一份 localStorage 偏好。
function LanguagePicker() {
const { t } = useTranslation();
const { updatePrefs } = useHotkeySettings();
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
setPref(next);
const resolved = await setLocalePreference(next);
const localePrefs = outputPrefsForLocale(resolved);
await updatePrefs(current => {
if (
current.chineseScriptPreference === localePrefs.chineseScriptPreference &&
current.outputLanguagePreference === localePrefs.outputLanguagePreference
) {
return current;
}
return { ...current, ...localePrefs };
});
};
return (
<select
value={pref}
onChange={e => apply(e.target.value as SupportedLocale | typeof FOLLOW_SYSTEM)}
style={{
height: 32, padding: '0 10px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, fontSize: 12.5,
fontFamily: 'inherit', outline: 'none',
background: 'var(--ol-surface-2)',
minWidth: 200, cursor: 'default',
}}
>
<option value={FOLLOW_SYSTEM}>{t('settings.language.followSystem')}</option>
<option value="zh-CN">{t('settings.language.zh')}</option>
<option value="zh-TW">{t('settings.language.zhTW')}</option>
<option value="en">{t('settings.language.en')}</option>
<option value="ja">{t('settings.language.ja')}</option>
<option value="ko">{t('settings.language.ko')}</option>
</select>
);
}
+20 -189
View File
@@ -1,6 +1,4 @@
import { useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { type CSSProperties, type ReactNode } from 'react';
export type OS = 'mac' | 'win' | 'linux';
@@ -18,21 +16,7 @@ export function detectOS(): OS {
const MAC_TITLEBAR_HEIGHT = 28;
const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 76;
export const WIN_TITLEBAR_HEIGHT = 36;
export const WIN_WINDOW_RADIUS = 10;
export const WIN_CONSOLE_RADIUS = 10;
const WIN_RESIZE_EDGE = 6;
const WIN_RESIZE_CORNER = 14;
type ResizeDirection =
| 'East'
| 'North'
| 'NorthEast'
| 'NorthWest'
| 'South'
| 'SouthEast'
| 'SouthWest'
| 'West';
const WIN_CONSOLE_RADIUS = 10;
interface WindowChromeProps {
os?: OS;
@@ -43,48 +27,46 @@ interface WindowChromeProps {
export function WindowChrome({
os = 'mac',
title = 'OpenLess',
children,
height = 800,
}: WindowChromeProps) {
const shellRadius = os === 'mac' ? 0 : os === 'win' ? WIN_WINDOW_RADIUS : 14;
// Windows 下交还原生外壳(decorations:true):外层不画圆角 / 边框 / 阴影 / 标题栏,
// 避免与原生窗口的角和关闭按钮重叠。内层卡片保留 10px 圆角,跟整体设计对齐。
const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14;
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14;
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : 0;
const background = os === 'win'
? 'linear-gradient(180deg, rgba(245,245,247,1) 0%, rgba(232,232,236,1) 100%)'
: `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
`;
return (
<div
style={{
'--ol-window-shell-radius': `${shellRadius}px`,
'--ol-window-console-radius': `${consoleRadius}px`,
'--ol-window-titlebar-height': `${os === 'mac' ? MAC_TITLEBAR_HEIGHT : WIN_TITLEBAR_HEIGHT}px`,
'--ol-window-titlebar-height': `${titlebarHeight}px`,
width: '100%',
height,
position: 'relative',
borderRadius: 'var(--ol-window-shell-radius)',
boxShadow: os === 'win'
? '0 22px 54px -28px rgba(15, 17, 22, 0.38), 0 10px 30px -20px rgba(15, 17, 22, 0.22), 0 0 0 0.5px rgba(0, 0, 0, 0.10)'
: 'var(--ol-shadow-xl)',
boxShadow: os === 'win' ? 'none' : 'var(--ol-shadow-xl)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
border: os === 'mac'
? 'none'
: os === 'win'
? '1px solid rgba(255,255,255,0.18)'
: '0.5px solid rgba(0,0,0,.10)',
background: `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
`,
backdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
WebkitBackdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
border: os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid rgba(0,0,0,.10)',
background,
backdropFilter: os === 'win' ? undefined : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
WebkitBackdropFilter: os === 'win' ? undefined : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
animation: os === 'win' ? undefined : 'ol-window-enter 0.42s var(--ol-motion-spring) both',
transition: 'box-shadow 0.28s var(--ol-motion-soft), border-color 0.28s var(--ol-motion-soft), backdrop-filter 0.28s var(--ol-motion-soft)',
willChange: 'opacity, transform, filter',
} as CSSProperties}
>
{os === 'win' && <WinTitleBar title={title} />}
{os === 'win' && <WindowsResizeHandles />}
{os === 'mac' && (
<div
data-tauri-drag-region
@@ -104,154 +86,3 @@ export function WindowChrome({
</div>
);
}
interface WinTitleBarProps {
title: string;
}
function WinTitleBar({ title }: WinTitleBarProps) {
const { t } = useTranslation();
return (
<div
style={{
height: WIN_TITLEBAR_HEIGHT,
flexShrink: 0,
display: 'flex',
alignItems: 'stretch',
position: 'relative',
zIndex: 70,
borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.20) 0%, rgba(255,255,255,0.08) 100%)',
}}
>
<div
data-tauri-drag-region
style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 14px', gap: 10 }}
>
<img src="AppIcon.png" alt="" style={{ width: 14, height: 14, borderRadius: 3 }} />
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{title}</span>
</div>
<div style={{ display: 'flex', pointerEvents: 'auto' }}>
<WinTitleButton title={t('windowChrome.minimize')} action="minimize">
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 5h10" stroke="currentColor" strokeWidth="1" /></svg>
</WinTitleButton>
<WinTitleButton title={t('windowChrome.maximize')} action="toggleMaximize">
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" strokeWidth="1" fill="none" /></svg>
</WinTitleButton>
<WinTitleButton title={t('windowChrome.close')} action="close" tone="danger">
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" /></svg>
</WinTitleButton>
</div>
</div>
);
}
function WindowsResizeHandles() {
const handles: Array<{
direction: ResizeDirection;
cursor: CSSProperties['cursor'];
style: CSSProperties;
}> = [
{ direction: 'North', cursor: 'ns-resize', style: { top: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } },
{ direction: 'South', cursor: 'ns-resize', style: { bottom: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } },
{ direction: 'West', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, left: 0, width: WIN_RESIZE_EDGE } },
{ direction: 'East', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, right: 0, width: WIN_RESIZE_EDGE } },
{ direction: 'NorthWest', cursor: 'nwse-resize', style: { top: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
{ direction: 'NorthEast', cursor: 'nesw-resize', style: { top: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
{ direction: 'SouthWest', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
{ direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
];
return (
<div aria-hidden style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 60 }}>
{handles.map(handle => (
<div
key={handle.direction}
onMouseDown={event => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
void startResizeDragging(handle.direction);
}}
style={{
position: 'absolute',
pointerEvents: 'auto',
cursor: handle.cursor,
...handle.style,
}}
/>
))}
</div>
);
}
interface WinTitleButtonProps {
title: string;
action: 'minimize' | 'toggleMaximize' | 'close';
tone?: 'default' | 'danger';
children: ReactNode;
}
function WinTitleButton({ title, action, tone = 'default', children }: WinTitleButtonProps) {
const [hovered, setHovered] = useState(false);
const [pressed, setPressed] = useState(false);
const danger = tone === 'danger';
const background = pressed
? danger ? '#c42b1c' : 'rgba(0, 0, 0, 0.12)'
: hovered ? danger ? '#e81123' : 'rgba(0, 0, 0, 0.08)'
: 'transparent';
const color = danger && (hovered || pressed) ? '#fff' : 'var(--ol-ink-3)';
return (
<button
style={{ ...winBtnStyle, background, color }}
title={title}
onClick={() => runWindowsWindowAction(action)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => {
setHovered(false);
setPressed(false);
}}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
>
{children}
</button>
);
}
async function startResizeDragging(direction: ResizeDirection) {
try {
await getCurrentWindow().startResizeDragging(direction);
} catch (error) {
console.warn(`[window] Windows resize ${direction} failed`, error);
}
}
async function runWindowsWindowAction(action: 'minimize' | 'toggleMaximize' | 'close') {
try {
const currentWindow = getCurrentWindow();
if (action === 'minimize') {
await currentWindow.minimize();
} else if (action === 'toggleMaximize') {
await currentWindow.toggleMaximize();
} else {
await currentWindow.close();
}
} catch (error) {
console.warn(`[window] Windows title button ${action} failed`, error);
}
}
const winBtnStyle: CSSProperties = {
width: 46,
height: '100%',
border: 0,
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--ol-ink-3)',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};
+288 -14
View File
@@ -1,24 +1,298 @@
// SelectLite — dropdown-styled display used in the Settings modal sub-sections.
// SelectLite — 受控的下拉组件,替代 native <select> 以避开 Windows Win32 ComboBox
// 弹框的直角丑框(issue #418)。
//
// 设计:
// - 触发器是一个 button(chevron + 当前值标签),样式可被 `style` 覆盖
// - popover 用 portal 渲染到 document.body,避开父容器 overflow:hidden
// - 键盘:ArrowDown/ArrowUp 切换高亮,Enter 确认,Esc 关闭
// - 点击外部 / 滚动 / resize 都会关闭或重定位
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { Icon } from '../Icon';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectLiteProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
style?: CSSProperties;
ariaLabel?: string;
}
export function SelectLite({ value }: SelectLiteProps) {
const DEFAULT_TRIGGER_STYLE: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
padding: '0 10px',
height: 32,
fontSize: 12.5,
fontFamily: 'inherit',
borderRadius: 8,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink)',
cursor: 'default',
outline: 'none',
textAlign: 'left',
minWidth: 160,
};
export function SelectLite({
value,
onChange,
options,
placeholder,
disabled = false,
style,
ariaLabel,
}: SelectLiteProps) {
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState<number>(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [anchor, setAnchor] = useState<{ left: number; top: number; width: number } | null>(null);
const selected = useMemo(
() => options.find(opt => opt.value === value),
[options, value],
);
const displayLabel = selected?.label ?? placeholder ?? '';
const positionPopover = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const popoverRect = popoverRef.current?.getBoundingClientRect();
const popoverHeight = popoverRect?.height ?? 280;
const popoverWidth = popoverRect?.width ?? rect.width;
// 纵向:默认在触发器下方;若下方空间放不下 popover,翻转向上以避免被视口裁剪。
const spaceBelow = window.innerHeight - rect.bottom;
const flipUp = spaceBelow < popoverHeight + 8 && rect.top > popoverHeight + 8;
const top = flipUp ? rect.top - popoverHeight - 4 : rect.bottom + 4;
// 横向:在窗口右边的 select 可能让 popover 溢出屏幕;clamp 到 [8, viewport - width - 8] 区间。
const minLeft = 8;
const maxLeft = Math.max(minLeft, window.innerWidth - popoverWidth - 8);
const left = Math.min(Math.max(rect.left, minLeft), maxLeft);
setAnchor({ left, top, width: rect.width });
}, []);
useLayoutEffect(() => {
if (!open) return;
positionPopover();
const handleReflow = () => positionPopover();
window.addEventListener('resize', handleReflow);
window.addEventListener('scroll', handleReflow, true);
return () => {
window.removeEventListener('resize', handleReflow);
window.removeEventListener('scroll', handleReflow, true);
};
}, [open, positionPopover]);
// 键盘 ArrowUp/Down 改 highlight 后把高亮项 scroll into view —— 长 dropdown 超过
// maxHeight 280 时键盘用户能看到当前高亮。{ block: 'nearest' } 避免把已经可见的项
// 强行滚到顶部,符合 listbox 滚动惯例。
useEffect(() => {
if (!open || highlight < 0) return;
const target = popoverRef.current?.querySelector(
`[data-option-index="${highlight}"]`,
) as HTMLElement | null;
target?.scrollIntoView({ block: 'nearest' });
}, [highlight, open]);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [open]);
const openMenu = () => {
if (disabled) return;
const initial = options.findIndex(opt => opt.value === value && !opt.disabled);
setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled));
setOpen(true);
};
const closeMenu = () => {
setOpen(false);
setHighlight(-1);
};
const selectIndex = (index: number) => {
const option = options[index];
if (!option || option.disabled) return;
onChange(option.value);
closeMenu();
triggerRef.current?.focus();
};
const moveHighlight = (direction: 1 | -1) => {
if (options.length === 0) return;
let next = highlight;
for (let i = 0; i < options.length; i += 1) {
next = (next + direction + options.length) % options.length;
if (!options[next]?.disabled) {
setHighlight(next);
return;
}
}
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (!open) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openMenu();
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveHighlight(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(-1);
} else if (event.key === 'Enter') {
event.preventDefault();
if (highlight >= 0) selectIndex(highlight);
} else if (event.key === 'Tab') {
closeMenu();
}
};
const triggerStyle: CSSProperties = {
...DEFAULT_TRIGGER_STYLE,
...style,
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'default',
};
return (
<div
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 10px', fontSize: 12.5,
borderRadius: 8, border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
minWidth: 200, justifyContent: 'space-between',
}}
>
<span>{value}</span>
<Icon name="chevDown" size={11} />
</div>
<>
<button
ref={triggerRef}
type="button"
className="ol-focus-ring"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-disabled={disabled}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => (open ? closeMenu() : openMenu())}
onKeyDown={handleKeyDown}
style={triggerStyle}
>
<span
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-4)',
}}
>
{displayLabel}
</span>
<Icon name="chevDown" size={11} />
</button>
{open && anchor && createPortal(
<div
ref={popoverRef}
role="listbox"
style={{
position: 'fixed',
left: anchor.left,
top: anchor.top,
minWidth: anchor.width,
maxHeight: 280,
overflowY: 'auto',
padding: 4,
borderRadius: 10,
border: '0.5px solid rgba(0, 0, 0, 0.10)',
background: 'rgba(252, 252, 254, 0.94)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: '0 12px 30px -10px rgba(15, 17, 22, 0.25), 0 0 0 0.5px rgba(0, 0, 0, 0.06)',
zIndex: 9999,
fontFamily: 'inherit',
fontSize: 12.5,
animation: 'ol-select-pop .14s var(--ol-motion-quick) both',
}}
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlight;
return (
<div
key={option.value || `__opt_${index}`}
data-option-index={index}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onMouseEnter={() => !option.disabled && setHighlight(index)}
onMouseDown={event => {
event.preventDefault();
selectIndex(index);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 10px',
borderRadius: 6,
cursor: option.disabled ? 'not-allowed' : 'default',
opacity: option.disabled ? 0.45 : 1,
background: isHighlighted && !option.disabled
? 'rgba(37, 99, 235, 0.10)'
: 'transparent',
color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)',
fontWeight: isSelected ? 600 : 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
transition: 'background 0.10s var(--ol-motion-quick)',
}}
>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{option.label}
</span>
{isSelected && <Icon name="check" size={12} />}
</div>
);
})}
</div>,
document.body,
)}
</>
);
}
@@ -10,11 +10,14 @@ export function SwitchLite({ on: initial = false }: SwitchLiteProps) {
const [on, setOn] = useState(initial);
return (
<button
type="button"
className="ol-focus-ring"
onClick={() => setOn(!on)}
style={{
position: 'relative', width: 32, height: 18, borderRadius: 999, border: 0,
background: on ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
cursor: 'default',
outline: 'none',
}}
>
<span
-1
View File
@@ -600,7 +600,6 @@ export const en: typeof zhCN = {
appearanceSystem: 'Follow system',
appearanceLight: 'Light',
appearanceDark: 'Dark',
language: 'Interface language',
font: 'Font size',
fontDesc: 'Scale the entire UI font size — applies instantly.',
fontSmall: 'Small',
-1
View File
@@ -602,7 +602,6 @@ export const ja: typeof zhCN = {
appearanceSystem: 'システムに従う',
appearanceLight: 'ライト',
appearanceDark: 'ダーク',
language: '表示言語',
font: 'フォントサイズ',
fontDesc: 'UI のフォントサイズを全体的にスケール。即時反映。',
fontSmall: '小',
-1
View File
@@ -602,7 +602,6 @@ export const ko: typeof zhCN = {
appearanceSystem: '시스템 따라가기',
appearanceLight: '라이트',
appearanceDark: '다크',
language: '인터페이스 언어',
font: '글꼴 크기',
fontDesc: 'UI 글꼴 크기를 전체 스케일. 즉시 반영.',
fontSmall: '소',
-1
View File
@@ -598,7 +598,6 @@ export const zhCN = {
appearanceSystem: '跟随系统',
appearanceLight: '浅色',
appearanceDark: '深色',
language: '界面语言',
font: '字体大小',
fontDesc: '整体缩放界面字号,立即生效。',
fontSmall: '小',
-1
View File
@@ -600,7 +600,6 @@ export const zhTW: typeof zhCN = {
appearanceSystem: '跟隨系統',
appearanceLight: '淺色',
appearanceDark: '深色',
language: '界面語言',
font: '字體大小',
fontDesc: '整體縮放界面字號,立即生效。',
fontSmall: '小',
+2 -1
View File
@@ -26,7 +26,8 @@ export interface CapsuleMessageLayout {
export function getCapsulePillMetrics(os: OS): CapsulePillMetrics {
if (os === 'win') {
// Windows metrics describe the visible outer footprint of the pill.
return { width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' };
// 与 macOS pill 接近以保持视觉密度一致;保留 ~4-5% 余量适配 Windows 字体 metrics。
return { width: 180, height: 44, textWidth: 88, boxSizing: 'border-box' };
}
return { width: 176, height: 42, textWidth: 84, boxSizing: 'border-box' };
+52 -82
View File
@@ -47,6 +47,7 @@ import {
} from '../lib/localAsr';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { detectOS } from '../components/WindowChrome';
import { SelectLite } from '../components/ui/SelectLite';
import { Btn, Card, PageHeader, Pill } from './_atoms';
// Foundry Local Whisper 后端只在 Windows 编译实体(foundry_local_sdk 仅 Windows),
@@ -626,73 +627,52 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('localAsr.foundrySelectedModel')}
<select
<SelectLite
value={selectedFoundryAlias}
onChange={e => {
onChange={next => {
foundrySelectionDirty.current = true;
setSelectedFoundryAlias(e.target.value as FoundryLocalAsrModelAlias);
setSelectedFoundryAlias(next as FoundryLocalAsrModelAlias);
}}
disabled={foundryBusy !== null}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 260,
}}>
{FOUNDRY_LOCAL_ASR_MODELS.map(model => {
options={FOUNDRY_LOCAL_ASR_MODELS.map(model => {
const catalog = foundryCatalog.find(item => item.alias === model.alias);
const sizeMb = formatFoundrySizeMb(catalog?.fileSizeMb);
return (
<option key={model.alias} value={model.alias}>
{t(model.labelKey)}
{sizeMb ? ` · ${t('localAsr.foundryApproxSizeMb', { mb: sizeMb })}` : ''}
</option>
);
const sizeSuffix = sizeMb ? ` · ${t('localAsr.foundryApproxSizeMb', { mb: sizeMb })}` : '';
return { value: model.alias, label: `${t(model.labelKey)}${sizeSuffix}` };
})}
</select>
ariaLabel={t('localAsr.foundrySelectedModel')}
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 260 }}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('localAsr.foundryRuntimeSourceLabel')}
<select
<SelectLite
value={selectedFoundryRuntimeSource}
onChange={e => void handleFoundryRuntimeSourceChange(e.target.value as FoundryRuntimeSource)}
onChange={next => void handleFoundryRuntimeSourceChange(next as FoundryRuntimeSource)}
disabled={foundryBusy !== null}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value="auto">{t('localAsr.foundryRuntimeSourceAuto')}</option>
<option value="nuget">{t('localAsr.foundryRuntimeSourceNuget')}</option>
<option value="ort-nightly">{t('localAsr.foundryRuntimeSourceOrtNightly')}</option>
</select>
options={[
{ value: 'auto', label: t('localAsr.foundryRuntimeSourceAuto') },
{ value: 'nuget', label: t('localAsr.foundryRuntimeSourceNuget') },
{ value: 'ort-nightly', label: t('localAsr.foundryRuntimeSourceOrtNightly') },
]}
ariaLabel={t('localAsr.foundryRuntimeSourceLabel')}
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('localAsr.foundryLanguageLabel')}
<select
<SelectLite
value={selectedFoundryLanguageHint}
onChange={e => void handleFoundryLanguageChange(e.target.value as FoundryLocalAsrLanguageHint)}
onChange={next => void handleFoundryLanguageChange(next as FoundryLocalAsrLanguageHint)}
disabled={foundryBusy !== null}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 132,
}}>
<option value="">{t('localAsr.foundryLanguageAuto')}</option>
<option value="zh">{t('localAsr.foundryLanguageZh')}</option>
<option value="en">{t('localAsr.foundryLanguageEn')}</option>
</select>
options={[
{ value: '', label: t('localAsr.foundryLanguageAuto') },
{ value: 'zh', label: t('localAsr.foundryLanguageZh') },
{ value: 'en', label: t('localAsr.foundryLanguageEn') },
]}
ariaLabel={t('localAsr.foundryLanguageLabel')}
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 132 }}
/>
</label>
</div>
</div>
@@ -805,21 +785,16 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
{t('localAsr.mirrorDesc')}
</div>
</div>
<select
<SelectLite
value={settings?.mirror ?? 'huggingface'}
onChange={e => void handleMirrorChange(e.target.value)}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value="huggingface">{t('localAsr.mirrorHuggingface')}</option>
<option value="hf-mirror">{t('localAsr.mirrorHfMirror')}</option>
</select>
onChange={next => void handleMirrorChange(next)}
options={[
{ value: 'huggingface', label: t('localAsr.mirrorHuggingface') },
{ value: 'hf-mirror', label: t('localAsr.mirrorHfMirror') },
]}
ariaLabel={t('localAsr.mirrorLabel')}
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }}
/>
</div>
</Card>
@@ -859,24 +834,19 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
{t('localAsr.keepLoadedDesc')}
</div>
</div>
<select
value={engineStatus?.keepLoadedSecs ?? 300}
onChange={e => void handleKeepLoadedChange(Number(e.target.value))}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value={0}>{t('localAsr.keepImmediate')}</option>
<option value={60}>{t('localAsr.keep1min')}</option>
<option value={300}>{t('localAsr.keep5min')}</option>
<option value={1800}>{t('localAsr.keep30min')}</option>
<option value={86400}>{t('localAsr.keepForever')}</option>
</select>
<SelectLite
value={String(engineStatus?.keepLoadedSecs ?? 300)}
onChange={next => void handleKeepLoadedChange(Number(next))}
options={[
{ value: '0', label: t('localAsr.keepImmediate') },
{ value: '60', label: t('localAsr.keep1min') },
{ value: '300', label: t('localAsr.keep5min') },
{ value: '1800', label: t('localAsr.keep30min') },
{ value: '86400', label: t('localAsr.keepForever') },
]}
ariaLabel={t('localAsr.keepLoadedLabel')}
style={{ fontSize: 13, background: 'var(--ol-surface)', minWidth: 200 }}
/>
</div>
</div>
</Card>
+49 -42
View File
@@ -37,6 +37,7 @@ import type {
} from '../lib/types';
import { emitSaved } from '../lib/savedEvent';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { SelectLite } from '../components/ui/SelectLite';
import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms';
import {
deleteLocalAsrModel,
@@ -454,15 +455,17 @@ function RecordingSection() {
label={t('settings.recording.pasteShortcutLabel')}
desc={t('settings.recording.pasteShortcutDesc')}
>
<select
<SelectLite
value={prefs.pasteShortcut}
onChange={e => onPasteShortcutChange(e.target.value as PasteShortcut)}
onChange={next => onPasteShortcutChange(next as PasteShortcut)}
options={[
{ value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') },
{ value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') },
{ value: 'shiftInsert', label: t('settings.recording.pasteShortcutShiftInsert') },
]}
ariaLabel={t('settings.recording.pasteShortcutLabel')}
style={{ ...inputStyle, maxWidth: 220 }}
>
<option value="ctrlV">{t('settings.recording.pasteShortcutCtrlV')}</option>
<option value="ctrlShiftV">{t('settings.recording.pasteShortcutCtrlShiftV')}</option>
<option value="shiftInsert">{t('settings.recording.pasteShortcutShiftInsert')}</option>
</select>
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
@@ -1386,15 +1389,16 @@ function ProvidersSection() {
{/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行,
视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */}
<SettingRow label={t('settings.providers.providerLabel')}>
<select
<SelectLite
value={llmProvider}
onChange={e => onLlmProviderChange(e.target.value as LlmPresetId)}
onChange={next => onLlmProviderChange(next as LlmPresetId)}
options={LLM_PRESETS.map(p => ({
value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`),
}))}
ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, maxWidth: 200 }}
>
{LLM_PRESETS.map(p => (
<option key={p.id} value={p.id}>{t(`settings.providers.presets.${p.nameKey}`)}</option>
))}
</select>
/>
</SettingRow>
{codexOAuthSelected ? (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
@@ -1455,30 +1459,33 @@ function ProvidersSection() {
: null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', minWidth: 0 }}>
<select
<SelectLite
value={selectedValue}
disabled={isLocked}
onChange={e => onAsrProviderChange(e.target.value as AsrPresetId)}
style={{
...inputStyle,
maxWidth: 200,
...(isLocked ? { opacity: 0.65, cursor: 'not-allowed' } : {}),
}}
>
{visibleAsrPresets.map(p => (
<option key={p.id} value={p.id}>{t(`settings.providers.presets.${p.nameKey}`)}</option>
))}
{platformLocalAsr && platformLocalNameKey && (
<option key={platformLocalAsr} value={platformLocalAsr} disabled>
{t(`settings.providers.presets.${platformLocalNameKey}`)}
</option>
)}
{anomalousLocal && anomalousNameKey && (
<option key={anomalousLocal} value={anomalousLocal} disabled>
{t(`settings.providers.presets.${anomalousNameKey}`)}
</option>
)}
</select>
onChange={next => onAsrProviderChange(next as AsrPresetId)}
options={[
...visibleAsrPresets.map(p => ({
value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`),
})),
...(platformLocalAsr && platformLocalNameKey
? [{
value: platformLocalAsr,
label: t(`settings.providers.presets.${platformLocalNameKey}`),
disabled: true,
}]
: []),
...(anomalousLocal && anomalousNameKey
? [{
value: anomalousLocal,
label: t(`settings.providers.presets.${anomalousNameKey}`),
disabled: true,
}]
: []),
]}
ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, maxWidth: 200 }}
/>
{platformLocalAsr && platformLocalNameKey && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5, maxWidth: 320 }}>
{t('settings.providers.localAsrTakeoverHint', {
@@ -1623,15 +1630,15 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' |
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
{models.length > 0 && (
<select
<SelectLite
value={selectedModel}
onChange={e => applyModel(e.target.value)}
onChange={applyModel}
disabled={status === 'loading'}
options={models.map(model => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')}
style={{ ...inputStyle, maxWidth: 220 }}
>
<option value="" disabled>{t('settings.providers.selectModel')}</option>
{models.map(model => <option key={model} value={model}>{model}</option>)}
</select>
/>
)}
</div>
{message && (
+14 -22
View File
@@ -4,10 +4,11 @@
// - 选一个翻译目标语言(单选;选"不启用"则 Shift 不触发翻译)
// - 看完整使用说明(怎么触发、按钮位置、胶囊显示)
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SavedToast } from '../components/SavedToast';
import { SelectLite } from '../components/ui/SelectLite';
import { SUPPORTED_LANGUAGES } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { formatComboLabel } from '../lib/hotkey';
@@ -120,6 +121,11 @@ export function Translation() {
const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey);
const enabled = prefs.translationTargetLanguage.trim() !== '';
const targetOptions = useMemo(() => ([
{ value: '', label: t('translation.target.disabled') },
...SUPPORTED_LANGUAGES.map(lang => ({ value: lang, label: lang })),
]), [t]);
return (
<>
<PageHeader title={t('translation.title')} />
@@ -216,28 +222,14 @@ export function Translation() {
{enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')}
</span>
</div>
<select
<SelectLite
value={prefs.translationTargetLanguage}
onChange={e => onTargetChange(e.target.value)}
style={{
width: '100%',
maxWidth: 360,
height: 32,
padding: '0 10px',
fontSize: 13,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: '#fff',
color: 'var(--ol-ink)',
fontFamily: 'inherit',
cursor: 'default',
}}
>
<option value="">{t('translation.target.disabled')}</option>
{SUPPORTED_LANGUAGES.map(lang => (
<option key={lang} value={lang}>{lang}</option>
))}
</select>
onChange={onTargetChange}
options={targetOptions}
placeholder={t('translation.target.disabled')}
ariaLabel={t('translation.target.title')}
style={{ width: '100%', maxWidth: 360, fontSize: 13, background: '#fff' }}
/>
</Card>
{/* 3. 使用方法 */}
@@ -1,7 +1,7 @@
// 语言切换面板:跟随系统 / 简中 / 繁中 / 英文 / 日文 (Beta) / 韩文 (Beta)。
// 切换语言同时把对应的 outputPrefs(中文偏好、输出语言)合并进 prefs。
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import {
@@ -11,14 +11,24 @@ import {
setLocalePreference,
type SupportedLocale,
} from '../../i18n';
import { SelectLite } from '../../components/ui/SelectLite';
import { Card } from '../_atoms';
import { SettingRow, inputStyle } from './shared';
import { SettingRow } from './shared';
export function LanguageSection() {
const { t } = useTranslation();
const { updatePrefs } = useHotkeySettings();
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
const options = useMemo(() => ([
{ value: FOLLOW_SYSTEM, label: t('settings.language.followSystem') },
{ value: 'zh-CN', label: t('settings.language.zh') },
{ value: 'zh-TW', label: t('settings.language.zhTW') },
{ value: 'en', label: t('settings.language.en') },
{ value: 'ja', label: t('settings.language.ja') },
{ value: 'ko', label: t('settings.language.ko') },
]), [t]);
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
setPref(next);
const resolved = await setLocalePreference(next);
@@ -39,18 +49,13 @@ export function LanguageSection() {
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.language.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{t('settings.language.desc')}</div>
<SettingRow label={t('settings.language.label')} desc={t('settings.language.labelDesc')}>
<select
<SelectLite
value={pref}
onChange={e => apply(e.target.value as SupportedLocale | typeof FOLLOW_SYSTEM)}
style={{ ...inputStyle, maxWidth: 220 }}
>
<option value={FOLLOW_SYSTEM}>{t('settings.language.followSystem')}</option>
<option value="zh-CN">{t('settings.language.zh')}</option>
<option value="zh-TW">{t('settings.language.zhTW')}</option>
<option value="en">{t('settings.language.en')}</option>
<option value="ja">{t('settings.language.ja')}</option>
<option value="ko">{t('settings.language.ko')}</option>
</select>
onChange={next => apply(next as SupportedLocale | typeof FOLLOW_SYSTEM)}
options={options}
ariaLabel={t('settings.language.label')}
style={{ maxWidth: 220, minWidth: 200 }}
/>
</SettingRow>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 8, lineHeight: 1.6 }}>
{t('settings.language.restartHint')}
+12
View File
@@ -27,6 +27,18 @@ button {
padding: 0;
}
@keyframes ol-select-pop {
from { opacity: 0; transform: translateY(-4px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* 键盘焦点指示:鼠标点击 (:focus) 不显示,键盘 Tab (:focus-visible) 才显示。
用 box-shadow 而非 outline 是因为组件内联 style 可能用 outline:none
把默认 ring 干掉,box-shadow 不受影响仍能上色。配合 pr-agent #407 的 a11y 要求。 */
.ol-focus-ring:focus-visible {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.45);
}
input, textarea {
font-family: inherit;
user-select: text;