Merge pull request #6713 from menloresearch/fix/theme-system
fix: theme system cross platform
This commit is contained in:
commit
0588cb34c6
@ -12,6 +12,8 @@
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:event:allow-listen",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-open",
|
||||
"core:app:allow-set-app-theme",
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "logs-app-window",
|
||||
"identifier": "log-app-window",
|
||||
"description": "enables permissions for the logs app window",
|
||||
"windows": ["logs-app-window"],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:event:allow-listen",
|
||||
"log:default",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-get-all-webviews",
|
||||
"core:window:allow-set-focus"
|
||||
]
|
||||
}
|
||||
|
||||
@ -3,12 +3,16 @@
|
||||
"identifier": "logs-window",
|
||||
"description": "enables permissions for the logs window",
|
||||
"windows": ["logs-window-local-api-server"],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:event:allow-listen",
|
||||
"log:default",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-get-all-webviews",
|
||||
"core:window:allow-set-focus"
|
||||
]
|
||||
}
|
||||
|
||||
@ -8,13 +8,28 @@
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:event:allow-listen",
|
||||
"log:default",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-get-all-webviews",
|
||||
"core:window:allow-set-focus",
|
||||
"hardware:allow-get-system-info",
|
||||
"hardware:allow-get-system-usage",
|
||||
"llamacpp:allow-get-devices",
|
||||
"llamacpp:allow-read-gguf-metadata",
|
||||
"deep-link:allow-get-current"
|
||||
"deep-link:allow-get-current",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*:*"
|
||||
},
|
||||
{
|
||||
"url": "http://*:*"
|
||||
}
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ use std::{
|
||||
};
|
||||
use tar::Archive;
|
||||
use tauri::{
|
||||
App, Emitter, Manager, Runtime, Wry
|
||||
App, Emitter, Manager, Runtime, Wry, WindowEvent
|
||||
};
|
||||
|
||||
#[cfg(desktop)]
|
||||
@ -270,3 +270,32 @@ pub fn setup_tray(app: &App) -> tauri::Result<TrayIcon> {
|
||||
})
|
||||
.build(app)
|
||||
}
|
||||
|
||||
pub fn setup_theme_listener<R: Runtime>(app: &App<R>) -> tauri::Result<()> {
|
||||
// Setup theme listener for main window
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
setup_window_theme_listener(app.handle().clone(), window);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_window_theme_listener<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
window: tauri::WebviewWindow<R>,
|
||||
) {
|
||||
let window_label = window.label().to_string();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::ThemeChanged(theme) = event {
|
||||
let theme_str = match theme {
|
||||
tauri::Theme::Light => "light",
|
||||
tauri::Theme::Dark => "dark",
|
||||
_ => "auto",
|
||||
};
|
||||
log::info!("System theme changed to: {} for window: {}", theme_str, window_label);
|
||||
let _ = app_handle_clone.emit("theme-changed", theme_str);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -117,3 +117,4 @@ pub fn is_library_available(library: &str) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -193,6 +193,7 @@ pub fn run() {
|
||||
}
|
||||
|
||||
setup_mcp(app);
|
||||
setup::setup_theme_listener(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"capabilities": ["default"],
|
||||
"capabilities": ["default", "logs-app-window", "logs-window", "system-monitor-window"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
|
||||
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: http:",
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
{
|
||||
"app": {
|
||||
"security": {
|
||||
"capabilities": ["desktop", "system-monitor-window"]
|
||||
"capabilities": [
|
||||
"desktop",
|
||||
"system-monitor-window",
|
||||
"log-app-window",
|
||||
"logs-window"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
{
|
||||
"app": {
|
||||
"security": {
|
||||
"capabilities": ["desktop", "system-monitor-window"]
|
||||
"capabilities": [
|
||||
"desktop",
|
||||
"system-monitor-window",
|
||||
"log-app-window",
|
||||
"logs-window"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
{
|
||||
"app": {
|
||||
"security": {
|
||||
"capabilities": ["desktop"]
|
||||
"capabilities": [
|
||||
"desktop",
|
||||
"system-monitor-window",
|
||||
"log-app-window",
|
||||
"logs-window"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<link rel="apple-touch-icon" href="/images/jan-logo.png" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>Jan</title>
|
||||
<!-- INJECT_GOOGLE_ANALYTICS -->
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useAppearance, isDefaultColor } from '@/hooks/useAppearance'
|
||||
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
|
||||
import { IconColorPicker } from '@tabler/icons-react'
|
||||
@ -14,6 +14,12 @@ export function ColorPickerAppBgColor() {
|
||||
const { appBgColor, setAppBgColor } = useAppearance()
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const showAlphaSlider = useBlurSupport()
|
||||
|
||||
// Helper to get alpha value based on blur support
|
||||
const getAlpha = (defaultAlpha: number) => {
|
||||
return showAlphaSlider ? defaultAlpha : 1
|
||||
}
|
||||
|
||||
const predefineAppBgColor: RgbaColor[] = [
|
||||
isDark
|
||||
@ -21,55 +27,64 @@ export function ColorPickerAppBgColor() {
|
||||
r: 25,
|
||||
g: 25,
|
||||
b: 25,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
||||
a: getAlpha(0.4),
|
||||
}
|
||||
: {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
||||
a: getAlpha(0.4),
|
||||
},
|
||||
{
|
||||
r: 70,
|
||||
g: 79,
|
||||
b: 229,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
||||
a: getAlpha(0.5),
|
||||
},
|
||||
{
|
||||
r: 238,
|
||||
g: 130,
|
||||
b: 238,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
||||
a: getAlpha(0.5),
|
||||
},
|
||||
|
||||
{
|
||||
r: 255,
|
||||
g: 99,
|
||||
b: 71,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
||||
a: getAlpha(0.5),
|
||||
},
|
||||
{
|
||||
r: 255,
|
||||
g: 165,
|
||||
b: 0,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
||||
a: getAlpha(0.5),
|
||||
},
|
||||
]
|
||||
|
||||
// Check if a color is the default color (considering both dark and light themes)
|
||||
const isColorDefault = (color: RgbaColor): boolean => {
|
||||
const isDarkDefault = color.r === 25 && color.g === 25 && color.b === 25
|
||||
const isLightDefault = color.r === 255 && color.g === 255 && color.b === 255
|
||||
// Accept both 0.4 and 1 as valid default alpha values (handles blur detection timing)
|
||||
const hasDefaultAlpha = Math.abs(color.a - 0.4) < 0.01 || Math.abs(color.a - 1) < 0.01
|
||||
return (isDarkDefault || isLightDefault) && hasDefaultAlpha
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{predefineAppBgColor.map((item, i) => {
|
||||
const isSelected =
|
||||
(item.r === appBgColor.r &&
|
||||
item.g === appBgColor.g &&
|
||||
item.b === appBgColor.b &&
|
||||
item.a === appBgColor.a) ||
|
||||
(isDefaultColor(appBgColor) && isDefaultColor(item))
|
||||
item.g === appBgColor.g &&
|
||||
item.b === appBgColor.b &&
|
||||
Math.abs(item.a - appBgColor.a) < 0.01) ||
|
||||
(isColorDefault(appBgColor) && isColorDefault(item))
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'size-4 rounded-full border border-main-view-fg/20',
|
||||
'size-4 rounded-full border border-main-view-fg/20 cursor-pointer',
|
||||
isSelected && 'ring-2 ring-accent border-none'
|
||||
)}
|
||||
onClick={() => {
|
||||
|
||||
@ -33,7 +33,7 @@ const DropdownAssistant = () => {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<div className="flex items-center justify-between gap-2 bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded-sm">
|
||||
<div className="inline-flex items-center justify-between gap-2 bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded-sm">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40">
|
||||
<div className="text-main-view-fg/80 flex items-center gap-1">
|
||||
|
||||
@ -643,7 +643,7 @@ const LeftPanel = () => {
|
||||
data-test-id={`menu-${menu.title}`}
|
||||
activeOptions={{ exact: true }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
|
||||
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 my-0.5 px-1 rounded',
|
||||
isActive && 'bg-left-panel-fg/10'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -31,6 +31,8 @@ vi.mock('zustand/middleware', () => ({
|
||||
// Mock global constants
|
||||
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
|
||||
Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true })
|
||||
Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true })
|
||||
Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true })
|
||||
Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true })
|
||||
|
||||
describe('useAppearance', () => {
|
||||
@ -217,7 +219,8 @@ describe('useAppearance', () => {
|
||||
result.current.setAppBgColor(testColor)
|
||||
})
|
||||
|
||||
expect(result.current.appBgColor).toEqual(testColor)
|
||||
// In web environment (IS_TAURI=false), alpha is forced to 1
|
||||
expect(result.current.appBgColor).toEqual({ ...testColor, a: 1 })
|
||||
})
|
||||
|
||||
it('should handle transparent colors', () => {
|
||||
@ -230,6 +233,36 @@ describe('useAppearance', () => {
|
||||
|
||||
expect(result.current.appAccentBgColor).toEqual(transparentColor)
|
||||
})
|
||||
|
||||
it('should preserve alpha when blur is supported (macOS)', () => {
|
||||
// Mock macOS environment
|
||||
Object.defineProperty(global, 'IS_MACOS', { value: true, writable: true })
|
||||
Object.defineProperty(global, 'IS_TAURI', { value: true, writable: true })
|
||||
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
|
||||
Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true })
|
||||
|
||||
const setPropertySpy = vi.fn()
|
||||
Object.defineProperty(document.documentElement, 'style', {
|
||||
value: {
|
||||
setProperty: setPropertySpy,
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAppearance())
|
||||
const testColor = { r: 128, g: 64, b: 192, a: 0.5 }
|
||||
|
||||
act(() => {
|
||||
result.current.setAppBgColor(testColor)
|
||||
})
|
||||
|
||||
// On macOS with Tauri, alpha should be preserved
|
||||
expect(result.current.appBgColor).toEqual(testColor)
|
||||
|
||||
// Reset for other tests
|
||||
Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true })
|
||||
Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
|
||||
@ -4,6 +4,9 @@ import { localStorageKey } from '@/constants/localStorage'
|
||||
import { RgbaColor } from 'react-colorful'
|
||||
import { rgb, oklch, formatCss } from 'culori'
|
||||
import { useTheme } from './useTheme'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getServiceHub } from '@/hooks/useServiceHub'
|
||||
import { supportsBlurEffects } from '@/utils/blurSupport'
|
||||
|
||||
export type FontSize = '14px' | '15px' | '16px' | '18px'
|
||||
export type ChatWidth = 'full' | 'compact'
|
||||
@ -41,19 +44,37 @@ export const fontSizeOptions = [
|
||||
{ label: 'Extra Large', value: '18px' as FontSize },
|
||||
]
|
||||
|
||||
// Helper to determine if blur effects are supported
|
||||
// This will be dynamically checked on Windows and Linux
|
||||
let blurEffectsSupported = true
|
||||
if ((IS_WINDOWS || IS_LINUX) && IS_TAURI) {
|
||||
// Default to false for Windows/Linux, will be checked async
|
||||
blurEffectsSupported = false
|
||||
}
|
||||
|
||||
// Helper to get the appropriate alpha value
|
||||
const getAlphaValue = () => {
|
||||
// Web always uses alpha = 1
|
||||
if (!IS_TAURI) return 1
|
||||
// Windows/Linux use 1 if blur not supported, 0.4 if supported
|
||||
if ((IS_WINDOWS || IS_LINUX) && !blurEffectsSupported) return 1
|
||||
// macOS and Windows/Linux with blur support use 0.4
|
||||
return 0.4
|
||||
}
|
||||
|
||||
// Default appearance settings
|
||||
const defaultFontSize: FontSize = '15px'
|
||||
const defaultAppBgColor: RgbaColor = {
|
||||
r: 25,
|
||||
g: 25,
|
||||
b: 25,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
||||
a: getAlphaValue(),
|
||||
}
|
||||
const defaultLightAppBgColor: RgbaColor = {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
||||
a: getAlphaValue(),
|
||||
}
|
||||
const defaultAppMainViewBgColor: RgbaColor = { r: 25, g: 25, b: 25, a: 1 }
|
||||
const defaultLightAppMainViewBgColor: RgbaColor = {
|
||||
@ -89,10 +110,15 @@ const isColorEqual = (color1: RgbaColor, color2: RgbaColor): boolean => {
|
||||
|
||||
// Helper function to check if color is default (not customized)
|
||||
export const isDefaultColor = (color: RgbaColor): boolean => {
|
||||
return (
|
||||
isColorEqual(color, defaultAppBgColor) ||
|
||||
isColorEqual(color, defaultLightAppBgColor)
|
||||
)
|
||||
// Check if RGB matches default (ignore alpha since it changes based on blur support)
|
||||
const isDarkDefault = color.r === 25 && color.g === 25 && color.b === 25
|
||||
const isLightDefault = color.r === 255 && color.g === 255 && color.b === 255
|
||||
|
||||
// Consider it default if RGB matches and alpha is either 0.4 or 1 (common values)
|
||||
const hasDefaultAlpha =
|
||||
Math.abs(color.a - 0.4) < 0.01 || Math.abs(color.a - 1) < 0.01
|
||||
|
||||
return (isDarkDefault || isLightDefault) && hasDefaultAlpha
|
||||
}
|
||||
|
||||
export const isDefaultColorMainView = (color: RgbaColor): boolean => {
|
||||
@ -128,6 +154,59 @@ export const getDefaultTextColor = (isDark: boolean): string => {
|
||||
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor
|
||||
}
|
||||
|
||||
// Hook to check if alpha slider should be shown
|
||||
export const useBlurSupport = () => {
|
||||
const [supportsBlur, setSupportsBlur] = useState(
|
||||
IS_MACOS && IS_TAURI // Default to true only for macOS
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const checkBlurSupport = async () => {
|
||||
if ((IS_WINDOWS || IS_LINUX) && IS_TAURI) {
|
||||
try {
|
||||
// Get hardware info to check OS version
|
||||
const hardwareInfo = await getServiceHub()
|
||||
.hardware()
|
||||
.getHardwareInfo()
|
||||
const supported = supportsBlurEffects(hardwareInfo)
|
||||
|
||||
blurEffectsSupported = supported
|
||||
setSupportsBlur(supported)
|
||||
|
||||
const platform = IS_WINDOWS ? 'Windows' : 'Linux'
|
||||
if (supported) {
|
||||
console.log(
|
||||
`✅ ${platform} blur effects: SUPPORTED - Alpha slider will be shown`
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
`❌ ${platform} blur effects: NOT SUPPORTED - Alpha slider will be hidden, alpha set to 1`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to check ${IS_WINDOWS ? 'Windows' : 'Linux'} blur support:`,
|
||||
error
|
||||
)
|
||||
setSupportsBlur(false)
|
||||
}
|
||||
} else if (IS_MACOS && IS_TAURI) {
|
||||
console.log(
|
||||
'🍎 macOS platform: Blur effects supported, alpha slider shown'
|
||||
)
|
||||
} else if (!IS_TAURI) {
|
||||
console.log('🌐 Web platform: Alpha slider hidden, alpha set to 1')
|
||||
}
|
||||
}
|
||||
|
||||
checkBlurSupport()
|
||||
}, [])
|
||||
|
||||
// Return true if alpha slider should be shown
|
||||
// Show on macOS (always), and conditionally on Windows/Linux based on detection
|
||||
return IS_TAURI && (IS_MACOS || supportsBlur)
|
||||
}
|
||||
|
||||
export const useAppearance = create<AppearanceState>()(
|
||||
persist(
|
||||
(set) => {
|
||||
@ -154,8 +233,11 @@ export const useAppearance = create<AppearanceState>()(
|
||||
defaultFontSize
|
||||
)
|
||||
|
||||
// Reset app background color
|
||||
const defaultBg = isDark ? defaultAppBgColor : defaultLightAppBgColor
|
||||
// Reset app background color with correct alpha based on blur support
|
||||
const currentAlpha = blurEffectsSupported && IS_TAURI ? 0.4 : 1
|
||||
const defaultBg = isDark
|
||||
? { r: 25, g: 25, b: 25, a: currentAlpha }
|
||||
: { r: 255, g: 255, b: 255, a: currentAlpha }
|
||||
const culoriRgbBg = rgb({
|
||||
mode: 'rgb',
|
||||
r: defaultBg.r / 255,
|
||||
@ -295,6 +377,11 @@ export const useAppearance = create<AppearanceState>()(
|
||||
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
|
||||
}
|
||||
|
||||
// Force alpha to 1 if blur effects are not supported
|
||||
if (!blurEffectsSupported && (IS_WINDOWS || IS_LINUX || !IS_TAURI)) {
|
||||
finalColor = { ...finalColor, a: 1 }
|
||||
}
|
||||
|
||||
// Convert RGBA to a format culori can work with
|
||||
const culoriRgb = rgb({
|
||||
mode: 'rgb',
|
||||
@ -565,11 +652,9 @@ export const useAppearance = create<AppearanceState>()(
|
||||
// Get the current theme state
|
||||
const { isDark } = useTheme.getState()
|
||||
|
||||
// If stored color is default, use theme-appropriate default
|
||||
let finalColor = state.appBgColor
|
||||
if (isDefaultColor(state.appBgColor)) {
|
||||
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
|
||||
}
|
||||
// Just use the stored color as-is during rehydration
|
||||
// The AppearanceProvider will handle alpha normalization after blur detection
|
||||
const finalColor = state.appBgColor
|
||||
|
||||
let finalColorMainView = state.appMainViewBgColor
|
||||
if (isDefaultColorMainView(state.appMainViewBgColor)) {
|
||||
|
||||
@ -32,7 +32,9 @@ export const useTheme = create<ThemeState>()(
|
||||
await getServiceHub().theme().setTheme(null)
|
||||
set(() => ({ activeTheme, isDark: isDarkMode }))
|
||||
} else {
|
||||
await getServiceHub().theme().setTheme(activeTheme as ThemeMode)
|
||||
await getServiceHub()
|
||||
.theme()
|
||||
.setTheme(activeTheme as ThemeMode)
|
||||
set(() => ({ activeTheme, isDark: activeTheme === 'dark' }))
|
||||
}
|
||||
},
|
||||
|
||||
@ -56,7 +56,6 @@
|
||||
@layer base {
|
||||
body {
|
||||
@apply overflow-hidden;
|
||||
background-color: white;
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppearance } from '@/hooks/useAppearance'
|
||||
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import {
|
||||
isDefaultColor,
|
||||
@ -29,14 +29,37 @@ export function AppearanceProvider() {
|
||||
appDestructiveTextColor,
|
||||
} = useAppearance()
|
||||
const { isDark } = useTheme()
|
||||
const showAlphaSlider = useBlurSupport()
|
||||
|
||||
// Force re-apply appearance on mount to fix theme desync issues on Windows
|
||||
// This ensures that when navigating to routes (like logs), the theme is properly applied
|
||||
useEffect(() => {
|
||||
const {
|
||||
setAppBgColor,
|
||||
setAppMainViewBgColor,
|
||||
appBgColor,
|
||||
appMainViewBgColor,
|
||||
} = useAppearance.getState()
|
||||
|
||||
// Re-trigger setters to ensure CSS variables are applied with correct theme
|
||||
setAppBgColor(appBgColor)
|
||||
setAppMainViewBgColor(appMainViewBgColor)
|
||||
}, []) // Run once on mount
|
||||
|
||||
// Update colors when blur support changes (important for Windows/Linux)
|
||||
useEffect(() => {
|
||||
const { setAppBgColor, appBgColor } = useAppearance.getState()
|
||||
// Re-apply color to update alpha based on blur support
|
||||
setAppBgColor(appBgColor)
|
||||
}, [showAlphaSlider])
|
||||
|
||||
// Apply appearance settings on mount and when they change
|
||||
useEffect(() => {
|
||||
// Apply font size
|
||||
document.documentElement.style.setProperty('--font-size-base', fontSize)
|
||||
|
||||
// Hide alpha slider when IS_LINUX || !IS_TAURI
|
||||
const shouldHideAlpha = IS_LINUX || !IS_TAURI
|
||||
// Hide alpha slider when blur is not supported
|
||||
const shouldHideAlpha = !showAlphaSlider
|
||||
let alphaStyleElement = document.getElementById('alpha-slider-style')
|
||||
|
||||
if (shouldHideAlpha) {
|
||||
@ -55,12 +78,13 @@ export function AppearanceProvider() {
|
||||
// Import culori functions dynamically to avoid SSR issues
|
||||
import('culori').then(({ rgb, oklch, formatCss }) => {
|
||||
// Convert RGBA to a format culori can work with
|
||||
// Use alpha = 1 when blur is not supported
|
||||
const culoriRgb = rgb({
|
||||
mode: 'rgb',
|
||||
r: appBgColor.r / 255,
|
||||
g: appBgColor.g / 255,
|
||||
b: appBgColor.b / 255,
|
||||
alpha: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : appBgColor.a,
|
||||
alpha: showAlphaSlider ? appBgColor.a : 1,
|
||||
})
|
||||
|
||||
const culoriRgbMainView = rgb({
|
||||
@ -176,6 +200,7 @@ export function AppearanceProvider() {
|
||||
appAccentTextColor,
|
||||
appDestructiveBgColor,
|
||||
appDestructiveTextColor,
|
||||
showAlphaSlider,
|
||||
])
|
||||
|
||||
// Update appearance when theme changes
|
||||
@ -194,6 +219,10 @@ export function AppearanceProvider() {
|
||||
setAppDestructiveBgColor,
|
||||
} = useAppearance.getState()
|
||||
|
||||
// Force re-apply all colors when theme changes to ensure correct dark/light defaults
|
||||
// This is especially important on Windows where the theme might not be properly
|
||||
// synchronized when navigating to different routes (e.g., logs page)
|
||||
|
||||
// If using default background color, update it when theme changes
|
||||
if (isDefaultColor(appBgColor)) {
|
||||
// This will trigger the appropriate updates for both background and text color
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTheme, checkOSDarkMode } from '@/hooks/useTheme'
|
||||
import { isPlatformTauri } from '@/lib/platform/utils'
|
||||
|
||||
/**
|
||||
* ThemeProvider ensures theme settings are applied on every page load
|
||||
@ -11,13 +12,22 @@ export function ThemeProvider() {
|
||||
|
||||
// Detect OS theme on mount and apply it
|
||||
useEffect(() => {
|
||||
// If theme is set to auto, detect OS preference
|
||||
if (activeTheme === 'auto') {
|
||||
const isDarkMode = checkOSDarkMode()
|
||||
setIsDark(isDarkMode)
|
||||
setTheme('auto')
|
||||
// Force refresh theme on mount to handle Linux startup timing issues
|
||||
const refreshTheme = () => {
|
||||
if (activeTheme === 'auto') {
|
||||
const isDarkMode = checkOSDarkMode()
|
||||
setIsDark(isDarkMode)
|
||||
setTheme('auto')
|
||||
}
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
refreshTheme()
|
||||
|
||||
// On Linux, desktop environment may not be ready immediately
|
||||
// Add a delayed refresh to catch the correct OS theme
|
||||
const timeoutId = setTimeout(refreshTheme, 100)
|
||||
|
||||
// Listen for changes in OS theme preference
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
@ -26,16 +36,41 @@ export function ThemeProvider() {
|
||||
if (activeTheme === 'auto') {
|
||||
setIsDark(e.matches)
|
||||
} else {
|
||||
setTheme(e.matches ? 'dark' : 'light')
|
||||
setTheme(activeTheme)
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
// Add event listener for browser/web
|
||||
mediaQuery.addEventListener('change', handleThemeChange)
|
||||
|
||||
// Listen to Tauri native theme events (uses XDG Desktop Portal on Linux)
|
||||
let unlistenTauri: (() => void) | undefined
|
||||
|
||||
if (isPlatformTauri()) {
|
||||
import('@tauri-apps/api/event')
|
||||
.then(({ listen }) => {
|
||||
return listen<string>('theme-changed', (event) => {
|
||||
if (activeTheme === 'auto') {
|
||||
const isDark = event.payload === 'dark'
|
||||
setIsDark(isDark)
|
||||
}
|
||||
})
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenTauri = unlisten
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to setup Tauri theme listener:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
mediaQuery.removeEventListener('change', handleThemeChange)
|
||||
if (unlistenTauri) {
|
||||
unlistenTauri()
|
||||
}
|
||||
}
|
||||
}, [activeTheme, setIsDark, setTheme])
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import SetupScreen from '@/containers/SetupScreen'
|
||||
import { route } from '@/constants/routes'
|
||||
|
||||
type SearchParams = {
|
||||
model?: {
|
||||
'model'?: {
|
||||
id: string
|
||||
provider: string
|
||||
}
|
||||
@ -33,7 +33,10 @@ export const Route = createFileRoute(route.home as any)({
|
||||
}
|
||||
|
||||
// Only include temporary-chat if it's explicitly true
|
||||
if (search[TEMPORARY_CHAT_QUERY_ID] === 'true' || search[TEMPORARY_CHAT_QUERY_ID] === true) {
|
||||
if (
|
||||
search[TEMPORARY_CHAT_QUERY_ID] === 'true' ||
|
||||
search[TEMPORARY_CHAT_QUERY_ID] === true
|
||||
) {
|
||||
result['temporary-chat'] = true
|
||||
}
|
||||
|
||||
@ -77,7 +80,7 @@ function Index() {
|
||||
</HeaderPage>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full overflow-y-auto flex flex-col gap-2 justify-center px-3 sm:px-4 md:px-8 py-4 md:py-0'
|
||||
'h-full overflow-y-auto inline-flex flex-col gap-2 justify-center px-3 sm:px-4 md:px-8 py-4 md:py-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -110,7 +113,9 @@ function Index() {
|
||||
isMobile ? 'text-base' : 'text-lg'
|
||||
)}
|
||||
>
|
||||
{isTemporaryChat ? t('chat:temporaryChatDescription') : t('chat:description')}
|
||||
{isTemporaryChat
|
||||
? t('chat:temporaryChatDescription')
|
||||
: t('chat:description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 shrink-0">
|
||||
|
||||
@ -31,17 +31,20 @@ function LogsViewer() {
|
||||
useEffect(() => {
|
||||
let lastLogsLength = 0
|
||||
function updateLogs() {
|
||||
serviceHub.app().readLogs().then((logData) => {
|
||||
let needScroll = false
|
||||
const filteredLogs = logData.filter(Boolean) as LogEntry[]
|
||||
if (filteredLogs.length > lastLogsLength) needScroll = true
|
||||
serviceHub
|
||||
.app()
|
||||
.readLogs()
|
||||
.then((logData) => {
|
||||
let needScroll = false
|
||||
const filteredLogs = logData.filter(Boolean) as LogEntry[]
|
||||
if (filteredLogs.length > lastLogsLength) needScroll = true
|
||||
|
||||
lastLogsLength = filteredLogs.length
|
||||
setLogs(filteredLogs)
|
||||
lastLogsLength = filteredLogs.length
|
||||
setLogs(filteredLogs)
|
||||
|
||||
// Scroll to bottom after initial logs are loaded
|
||||
if (needScroll) setTimeout(() => scrollToBottom(), 100)
|
||||
})
|
||||
// Scroll to bottom after initial logs are loaded
|
||||
if (needScroll) setTimeout(() => scrollToBottom(), 100)
|
||||
})
|
||||
}
|
||||
updateLogs()
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
* Tauri Theme Service - Desktop implementation
|
||||
*/
|
||||
|
||||
import { getCurrentWindow, Theme } from '@tauri-apps/api/window'
|
||||
import { Theme } from '@tauri-apps/api/window'
|
||||
import { getAllWebviewWindows, type WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import type { ThemeMode } from './types'
|
||||
import { DefaultThemeService } from './default'
|
||||
|
||||
@ -10,7 +11,27 @@ export class TauriThemeService extends DefaultThemeService {
|
||||
async setTheme(theme: ThemeMode): Promise<void> {
|
||||
try {
|
||||
const tauriTheme = theme as Theme | null
|
||||
await getCurrentWindow().setTheme(tauriTheme)
|
||||
|
||||
// Update all open windows, not just the current one
|
||||
const allWindows = await getAllWebviewWindows()
|
||||
|
||||
// Convert to array if it's not already
|
||||
const windowsArray: WebviewWindow[] = Array.isArray(allWindows)
|
||||
? allWindows
|
||||
: Object.values(allWindows)
|
||||
|
||||
await Promise.all(
|
||||
windowsArray.map(async (window) => {
|
||||
try {
|
||||
await window.setTheme(tauriTheme)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to set theme for window ${window.label}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error setting theme in Tauri:', error)
|
||||
throw error
|
||||
@ -21,7 +42,7 @@ export class TauriThemeService extends DefaultThemeService {
|
||||
return {
|
||||
setTheme: (theme: ThemeMode): Promise<void> => {
|
||||
return this.setTheme(theme)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,39 @@ import type { WindowConfig, WebviewWindowInstance } from './types'
|
||||
import { DefaultWindowService } from './default'
|
||||
|
||||
export class TauriWindowService extends DefaultWindowService {
|
||||
async createWebviewWindow(config: WindowConfig): Promise<WebviewWindowInstance> {
|
||||
async createWebviewWindow(
|
||||
config: WindowConfig
|
||||
): Promise<WebviewWindowInstance> {
|
||||
try {
|
||||
// Get current theme from localStorage
|
||||
const storedTheme = localStorage.getItem('jan-theme')
|
||||
let theme: 'light' | 'dark' | undefined = undefined
|
||||
|
||||
if (storedTheme) {
|
||||
try {
|
||||
const themeData = JSON.parse(storedTheme)
|
||||
const activeTheme = themeData?.state?.activeTheme
|
||||
const isDark = themeData?.state?.isDark
|
||||
|
||||
// Set theme based on stored preference
|
||||
if (activeTheme === 'auto') {
|
||||
theme = undefined // Let OS decide
|
||||
} else if (
|
||||
activeTheme === 'dark' ||
|
||||
(activeTheme === 'auto' && isDark)
|
||||
) {
|
||||
theme = 'dark'
|
||||
} else if (
|
||||
activeTheme === 'light' ||
|
||||
(activeTheme === 'auto' && !isDark)
|
||||
) {
|
||||
theme = 'light'
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse theme from localStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const webviewWindow = new WebviewWindow(config.label, {
|
||||
url: config.url,
|
||||
title: config.title,
|
||||
@ -20,8 +51,12 @@ export class TauriWindowService extends DefaultWindowService {
|
||||
maximizable: config.maximizable,
|
||||
closable: config.closable,
|
||||
fullscreen: config.fullscreen,
|
||||
theme: theme,
|
||||
})
|
||||
|
||||
// Setup theme listener for this window
|
||||
this.setupThemeListenerForWindow(webviewWindow)
|
||||
|
||||
return {
|
||||
label: config.label,
|
||||
async close() {
|
||||
@ -38,7 +73,7 @@ export class TauriWindowService extends DefaultWindowService {
|
||||
},
|
||||
async setTitle(title: string) {
|
||||
await webviewWindow.setTitle(title)
|
||||
}
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating Tauri window:', error)
|
||||
@ -46,7 +81,9 @@ export class TauriWindowService extends DefaultWindowService {
|
||||
}
|
||||
}
|
||||
|
||||
async getWebviewWindowByLabel(label: string): Promise<WebviewWindowInstance | null> {
|
||||
async getWebviewWindowByLabel(
|
||||
label: string
|
||||
): Promise<WebviewWindowInstance | null> {
|
||||
try {
|
||||
const existingWindow = await WebviewWindow.getByLabel(label)
|
||||
|
||||
@ -67,7 +104,7 @@ export class TauriWindowService extends DefaultWindowService {
|
||||
},
|
||||
async setTitle(title: string) {
|
||||
await existingWindow.setTitle(title)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,8 +172,35 @@ export class TauriWindowService extends DefaultWindowService {
|
||||
center: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error opening local API server logs window in Tauri:', error)
|
||||
console.error(
|
||||
'Error opening local API server logs window in Tauri:',
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private setupThemeListenerForWindow(window: WebviewWindow): void {
|
||||
// Listen to theme change events from Tauri backend
|
||||
import('@tauri-apps/api/event')
|
||||
.then(({ listen }) => {
|
||||
return listen<string>('theme-changed', async (event) => {
|
||||
const theme = event.payload
|
||||
try {
|
||||
if (theme === 'dark') {
|
||||
await window.setTheme('dark')
|
||||
} else if (theme === 'light') {
|
||||
await window.setTheme('light')
|
||||
} else {
|
||||
await window.setTheme(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update window theme:', err)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to setup theme listener for window:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
65
web-app/src/utils/blurSupport.ts
Normal file
65
web-app/src/utils/blurSupport.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Utility to check if the system supports blur/acrylic effects
|
||||
* based on OS information from hardware data
|
||||
*/
|
||||
|
||||
import type { HardwareData } from '@/hooks/useHardware'
|
||||
|
||||
/**
|
||||
* Check if Windows supports blur effects based on build number
|
||||
* Windows 10 build 17134 (version 1803) and later support acrylic effects
|
||||
*/
|
||||
function checkWindowsBlurSupport(osName: string): boolean {
|
||||
// os_name format: "Windows 10 Pro (build 22631)" or similar
|
||||
const buildMatch = osName.match(/build\s+(\d+)/i)
|
||||
|
||||
if (buildMatch && buildMatch[1]) {
|
||||
const build = parseInt(buildMatch[1], 10)
|
||||
return build >= 17134
|
||||
}
|
||||
|
||||
// If we can't detect build number, assume modern Windows supports blur
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Linux supports blur effects based on desktop environment
|
||||
*/
|
||||
function checkLinuxBlurSupport(): boolean {
|
||||
// Check environment variables (only available in Tauri)
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
// These checks would need to be done on the backend
|
||||
// For now, we'll assume Linux with common DEs supports blur
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the system supports blur/acrylic effects
|
||||
*
|
||||
* @param hardwareData - Hardware data from the hardware plugin
|
||||
* @returns true if blur effects are supported
|
||||
*/
|
||||
export function supportsBlurEffects(hardwareData: HardwareData | null): boolean {
|
||||
if (!hardwareData) return false
|
||||
|
||||
const { os_type, os_name } = hardwareData
|
||||
|
||||
// macOS always supports blur/vibrancy effects
|
||||
if (os_type === 'macos') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows: Check build number
|
||||
if (os_type === 'windows') {
|
||||
return checkWindowsBlurSupport(os_name)
|
||||
}
|
||||
|
||||
// Linux: Check desktop environment (simplified for now)
|
||||
if (os_type === 'linux') {
|
||||
return checkLinuxBlurSupport()
|
||||
}
|
||||
|
||||
// Unknown platforms: assume no blur support
|
||||
return false
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user