diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 8d1e8615992..cf886b8a549 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -49,6 +49,15 @@ import { audioEnhancementStore, generalSettingsStore, } from "~/store"; +import { + defaultKeyboardSettings, + KEYCAP_STYLE_OPTIONS, + KEYCAP_THEME_COLORS, + KEYCAP_THEME_OPTIONS, + type KeyboardSettings, + type KeycapStyle, + type KeycapTheme, +} from "~/store/keyboard"; import { createSelectedOrganization, getOrganizationBrandColorSwatches, @@ -111,7 +120,8 @@ import { } from "./context"; import { GradientEditor } from "./GradientEditor"; import { ImageSegmentConfig } from "./image-segment-config"; -import { KeyboardTab } from "./KeyboardTab"; +import { KeyboardTab } from "./keyboard-tab"; +import { KeycapPreviewCluster, parseShortcutKeys } from "./keycap-renderer"; import { encodeMaskEffect, getMaskEffect, @@ -625,16 +635,18 @@ function ConfigSidebarContent() { }); }; + type ConfigSidebarTab = + | "background" + | "camera" + | "transcript" + | "audio" + | "cursor" + | "keyboard" + | "hotkeys" + | "captions"; + const [state, setState] = createStore({ - selectedTab: "background" as - | "background" - | "camera" - | "transcript" - | "audio" - | "cursor" - | "keyboard" - | "hotkeys" - | "captions", + selectedTab: "background" as ConfigSidebarTab, }); // Clip selection is a timeline-only affordance (highlight, Delete key, @@ -684,6 +696,9 @@ function ConfigSidebarContent() { ? undefined : state.selectedTab } + onChange={(v) => { + if (v) setState("selectedTab", v as ConfigSidebarTab); + }} class="flex overflow-hidden z-10 flex-col flex-1 min-h-0 max-w-104 rounded-xl shrink-0 bg-ed-card shadow-ed-card" > @@ -3735,7 +3750,7 @@ function KeyboardSegmentConfig(props: { segmentIndex: number; segment: KeyboardTrackSegment; }) { - const { setProject } = useEditorContext(); + const { project, setProject } = useEditorContext(); const updateSegment = (fn: (segment: KeyboardTrackSegment) => void) => { setProject( @@ -3749,8 +3764,70 @@ function KeyboardSegmentConfig(props: { ); }; + const getSetting = ( + key: K, + ): NonNullable => { + const settings = project?.keyboard?.settings; + if (settings && key in settings) { + return (settings as Record)[ + key as string + ] as NonNullable; + } + return defaultKeyboardSettings[key] as NonNullable; + }; + + const updateSetting = ( + key: K, + value: KeyboardSettings[K], + ) => { + if (!project?.keyboard) { + setProject("keyboard", { + settings: { ...defaultKeyboardSettings, [key]: value }, + }); + return; + } + setProject("keyboard", "settings", key, value); + }; + + const updateSettings = (partial: Partial) => { + if (!project?.keyboard) { + setProject("keyboard", { + settings: { ...defaultKeyboardSettings, ...partial }, + }); + return; + } + for (const [key, value] of Object.entries(partial)) { + setProject("keyboard", "settings", key as keyof KeyboardSettings, value); + } + }; + + const previewKeys = createMemo(() => { + const text = props.segment.displayText || ""; + if (!text) return ["⌨"]; + return parseShortcutKeys(text); + }); + return (
+
+
+ Keycap Preview +
+ +
+
+ +
+
+ + + options={KEYCAP_STYLE_OPTIONS.map((s) => s.value)} + value={getSetting("style")} + onChange={(value) => { + if (value === null) return; + updateSetting("style", value); + }} + itemComponent={(p) => ( + as={KSelect.Item} item={p.item}> + + { + KEYCAP_STYLE_OPTIONS.find( + (s) => s.value === p.item.rawValue, + )?.label + } + + + )} + > + + class="truncate"> + {(state) => + KEYCAP_STYLE_OPTIONS.find( + (s) => s.value === state.selectedOption(), + )?.label + } + + + + + + + + as={KSelect.Content} + class={topSlideAnimateClasses} + > + + class="overflow-y-auto max-h-48" + as={KSelect.Listbox} + /> + + + + + + + + options={KEYCAP_THEME_OPTIONS.map((t) => t.value)} + value={getSetting("theme")} + onChange={(value) => { + if (value === null) return; + const themeColors = KEYCAP_THEME_COLORS[value]; + updateSettings({ + theme: value, + ...(themeColors + ? { + color: themeColors.text, + backgroundColor: themeColors.background, + } + : {}), + }); + }} + itemComponent={(p) => ( + as={KSelect.Item} item={p.item}> + + { + KEYCAP_THEME_OPTIONS.find( + (t) => t.value === p.item.rawValue, + )?.label + } + + + )} + > + + class="truncate"> + {(state) => + KEYCAP_THEME_OPTIONS.find( + (t) => t.value === state.selectedOption(), + )?.label + } + + + + + + + + as={KSelect.Content} + class={topSlideAnimateClasses} + > + + class="overflow-y-auto max-h-48" + as={KSelect.Listbox} + /> + + + + + + + updateSetting("keycapMode", checked)} + /> + + + + updateSetting("showChassis", checked)} + /> + +
+
+
diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index 3ef4105cf0f..b19f967f4b7 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -30,6 +30,7 @@ import { createCaptionTrackSegments } from "./captions"; import { type EditorPreviewQuality, FPS, useEditorContext } from "./context"; import { FrameButton } from "./FrameButton"; import { ImageOverlay } from "./image-overlay"; +import { KeyboardOverlay } from "./keyboard-overlay"; import { MaskOverlay } from "./MaskOverlay"; import { PerformanceOverlay } from "./PerformanceOverlay"; import { usePreparingEditor } from "./preparing-editor-context"; @@ -785,6 +786,7 @@ function PreviewCanvas(props: {
+ diff --git a/apps/desktop/src/routes/editor/keyboard-overlay.tsx b/apps/desktop/src/routes/editor/keyboard-overlay.tsx new file mode 100644 index 00000000000..f12092ebce4 --- /dev/null +++ b/apps/desktop/src/routes/editor/keyboard-overlay.tsx @@ -0,0 +1,86 @@ +import { createMemo, Show } from "solid-js"; +import { + defaultKeyboardSettings, + type KeycapStyle, + type KeycapTheme, +} from "~/store/keyboard"; +import { useEditorContext } from "./context"; +import { KeycapPreviewCluster, parseShortcutKeys } from "./keycap-renderer"; + +type KeyboardOverlayProps = { + size: { width: number; height: number }; +}; + +export function KeyboardOverlay(props: KeyboardOverlayProps) { + const { project, editorState } = useEditorContext(); + + const currentAbsoluteTime = () => + editorState.previewTime ?? editorState.playbackTime ?? 0; + + const settings = createMemo(() => ({ + ...defaultKeyboardSettings, + ...project.keyboard?.settings, + })); + + const isTrackEnabled = () => editorState.timeline.tracks.keyboard ?? true; + + const activeSegment = createMemo(() => { + if (!settings().enabled || !isTrackEnabled()) return null; + const time = currentAbsoluteTime(); + const segments = project.timeline?.keyboardSegments ?? []; + return segments.find((seg) => time >= seg.start && time <= seg.end) ?? null; + }); + + const parsedKeys = createMemo(() => { + const seg = activeSegment(); + if (!seg) return []; + return parseShortcutKeys(seg.displayText || ""); + }); + + const positionClasses = createMemo(() => { + const pos = settings().position || "bottom-center"; + switch (pos) { + case "top-left": + return "items-start justify-start pt-14 pl-12"; + case "top-center": + case "top": + return "items-start justify-center pt-14"; + case "top-right": + return "items-start justify-end pt-14 pr-12"; + case "bottom-left": + return "items-end justify-start pb-32 pl-12"; + case "bottom-right": + return "items-end justify-end pb-32 pr-12"; + default: + return "items-end justify-center pb-32"; + } + }); + + return ( + +
+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/editor/KeyboardTab.tsx b/apps/desktop/src/routes/editor/keyboard-tab.tsx similarity index 77% rename from apps/desktop/src/routes/editor/KeyboardTab.tsx rename to apps/desktop/src/routes/editor/keyboard-tab.tsx index 59593329c92..25d1d1090f1 100644 --- a/apps/desktop/src/routes/editor/KeyboardTab.tsx +++ b/apps/desktop/src/routes/editor/keyboard-tab.tsx @@ -6,7 +6,12 @@ import toast from "solid-toast"; import { Toggle } from "~/components/Toggle"; import { defaultKeyboardSettings, + KEYCAP_STYLE_OPTIONS, + KEYCAP_THEME_COLORS, + KEYCAP_THEME_OPTIONS, type KeyboardSettings, + type KeycapStyle, + type KeycapTheme, } from "~/store/keyboard"; import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding"; import { commands } from "~/utils/tauri"; @@ -17,6 +22,7 @@ import { generateForStableKeyboardTimeline, keyboardTimelineSignature, } from "./keyboard-timing"; +import { KeycapPreviewCluster } from "./keycap-renderer"; import { FONT_OPTIONS, getTextWeightLabel, @@ -76,6 +82,18 @@ export function KeyboardTab(props: { setProject("keyboard", "settings", key, value); }; + const updateSettings = (partial: Partial) => { + if (!project?.keyboard) { + setProject("keyboard", { + settings: { ...defaultKeyboardSettings, ...partial }, + }); + return; + } + for (const [key, value] of Object.entries(partial)) { + setProject("keyboard", "settings", key as keyof KeyboardSettings, value); + } + }; + const hasKeyboardSegments = createMemo( () => (project.timeline?.keyboardSegments?.length ?? 0) > 0, ); @@ -186,6 +204,149 @@ export function KeyboardTab(props: { !getSetting("enabled") && "opacity-50 pointer-events-none", )} > +
+
+ Keycap Preview +
+ +
+ +
+
+ + + options={KEYCAP_STYLE_OPTIONS.map((s) => s.value)} + value={getSetting("style")} + onChange={(value) => { + if (value === null) return; + updateSetting("style", value); + }} + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + { + KEYCAP_STYLE_OPTIONS.find( + (s) => s.value === props.item.rawValue, + )?.label + } + + + )} + > + + class="truncate"> + {(state) => + KEYCAP_STYLE_OPTIONS.find( + (s) => s.value === state.selectedOption(), + )?.label + } + + + + + + + + as={KSelect.Content} + class={topSlideAnimateClasses} + > + + class="overflow-y-auto max-h-48" + as={KSelect.Listbox} + /> + + + + + + + + options={KEYCAP_THEME_OPTIONS.map((t) => t.value)} + value={getSetting("theme")} + onChange={(value) => { + if (value === null) return; + const themeColors = KEYCAP_THEME_COLORS[value]; + updateSettings({ + theme: value, + ...(themeColors + ? { + color: themeColors.text, + backgroundColor: themeColors.background, + } + : {}), + }); + }} + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + { + KEYCAP_THEME_OPTIONS.find( + (t) => t.value === props.item.rawValue, + )?.label + } + + + )} + > + + class="truncate"> + {(state) => + KEYCAP_THEME_OPTIONS.find( + (t) => t.value === state.selectedOption(), + )?.label + } + + + + + + + + as={KSelect.Content} + class={topSlideAnimateClasses} + > + + class="overflow-y-auto max-h-48" + as={KSelect.Listbox} + /> + + + + + + + updateSetting("keycapMode", checked)} + /> + + + + updateSetting("showChassis", checked)} + /> + +
+
+ +
+
diff --git a/apps/desktop/src/routes/editor/keycap-renderer.tsx b/apps/desktop/src/routes/editor/keycap-renderer.tsx new file mode 100644 index 00000000000..f6ddb25e8a2 --- /dev/null +++ b/apps/desktop/src/routes/editor/keycap-renderer.tsx @@ -0,0 +1,701 @@ +import { createMemo, createUniqueId, For, Show } from "solid-js"; +import { + KEYCAP_THEME_COLORS, + type KeycapStyle, + type KeycapTheme, +} from "~/store/keyboard"; + +export interface KeycapColorPalette { + surfaceTop: string; + surfaceMid: string; + surfaceBottom: string; + surfaceStroke: string; + dishTop: string; + dishBottom: string; + textColor: string; +} + +export function adjustHex(hex: string, percent: number): string { + const clean = hex.replace("#", ""); + if (clean.length !== 6 && clean.length !== 3) return hex; + const fullHex = + clean.length === 3 + ? clean + .split("") + .map((c) => c + c) + .join("") + : clean; + const num = Number.parseInt(fullHex, 16); + if (Number.isNaN(num)) return hex; + const r = Math.min( + 255, + Math.max(0, Math.round(((num >> 16) & 255) + (255 * percent) / 100)), + ); + const g = Math.min( + 255, + Math.max(0, Math.round(((num >> 8) & 255) + (255 * percent) / 100)), + ); + const b = Math.min( + 255, + Math.max(0, Math.round((num & 255) + (255 * percent) / 100)), + ); + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; +} + +export const THEME_PALETTES: Record< + KeycapTheme, + { alpha: KeycapColorPalette; mod: KeycapColorPalette } +> = { + white: { + alpha: { + surfaceTop: "#F8F8F8", + surfaceMid: "#EFEFEF", + surfaceBottom: "#DCDCDC", + surfaceStroke: "#C7C7C7", + dishTop: "#FFFFFF", + dishBottom: "#F0F0F0", + textColor: "#2B2A27", + }, + mod: { + surfaceTop: "#F1F1F1", + surfaceMid: "#E5E5E5", + surfaceBottom: "#D2D2D2", + surfaceStroke: "#BFBFBF", + dishTop: "#FAFAFA", + dishBottom: "#E8E8E8", + textColor: "#1A1A1A", + }, + }, + black: { + alpha: { + surfaceTop: "#2C2C30", + surfaceMid: "#1E1E22", + surfaceBottom: "#141416", + surfaceStroke: "#42424A", + dishTop: "#242428", + dishBottom: "#1A1A1E", + textColor: "#F5F5F7", + }, + mod: { + surfaceTop: "#222226", + surfaceMid: "#18181A", + surfaceBottom: "#101012", + surfaceStroke: "#383840", + dishTop: "#1E1E22", + dishBottom: "#141416", + textColor: "#E2E2E6", + }, + }, + ocean: { + alpha: { + surfaceTop: "#3B82F6", + surfaceMid: "#2563EB", + surfaceBottom: "#1D4ED8", + surfaceStroke: "#60A5FA", + dishTop: "#60A5FA", + dishBottom: "#2563EB", + textColor: "#FFFFFF", + }, + mod: { + surfaceTop: "#2563EB", + surfaceMid: "#1D4ED8", + surfaceBottom: "#1E40AF", + surfaceStroke: "#3B82F6", + dishTop: "#3B82F6", + dishBottom: "#1D4ED8", + textColor: "#FFFFFF", + }, + }, + emerald: { + alpha: { + surfaceTop: "#10B981", + surfaceMid: "#059669", + surfaceBottom: "#047857", + surfaceStroke: "#34D399", + dishTop: "#34D399", + dishBottom: "#059669", + textColor: "#FFFFFF", + }, + mod: { + surfaceTop: "#059669", + surfaceMid: "#047857", + surfaceBottom: "#065F46", + surfaceStroke: "#10B981", + dishTop: "#10B981", + dishBottom: "#047857", + textColor: "#FFFFFF", + }, + }, + amber: { + alpha: { + surfaceTop: "#F59E0B", + surfaceMid: "#D97706", + surfaceBottom: "#B45309", + surfaceStroke: "#FBBF24", + dishTop: "#FBBF24", + dishBottom: "#D97706", + textColor: "#FFFFFF", + }, + mod: { + surfaceTop: "#D97706", + surfaceMid: "#B45309", + surfaceBottom: "#92400E", + surfaceStroke: "#F59E0B", + dishTop: "#F59E0B", + dishBottom: "#B45309", + textColor: "#FFFFFF", + }, + }, + rose: { + alpha: { + surfaceTop: "#F43F5E", + surfaceMid: "#E11D48", + surfaceBottom: "#BE123C", + surfaceStroke: "#FB7185", + dishTop: "#FB7185", + dishBottom: "#E11D48", + textColor: "#FFFFFF", + }, + mod: { + surfaceTop: "#E11D48", + surfaceMid: "#BE123C", + surfaceBottom: "#9F1239", + surfaceStroke: "#F43F5E", + dishTop: "#F43F5E", + dishBottom: "#BE123C", + textColor: "#FFFFFF", + }, + }, + purple: { + alpha: { + surfaceTop: "#8B5CF6", + surfaceMid: "#7C3AED", + surfaceBottom: "#6D28D9", + surfaceStroke: "#A78BFA", + dishTop: "#A78BFA", + dishBottom: "#7C3AED", + textColor: "#FFFFFF", + }, + mod: { + surfaceTop: "#7C3AED", + surfaceMid: "#6D28D9", + surfaceBottom: "#5B21B6", + surfaceStroke: "#8B5CF6", + dishTop: "#8B5CF6", + dishBottom: "#6D28D9", + textColor: "#FFFFFF", + }, + }, +}; + +export interface ChassisThemeTokens { + background: string; + border: string; + boxShadow: string; + backdropFilter?: string; +} + +export const THEME_CHASSIS: Record = { + white: { + background: "rgba(238, 238, 242, 0.94)", + border: "1.5px solid rgba(0, 0, 0, 0.12)", + boxShadow: + "0 12px 28px rgba(0, 0, 0, 0.16), 0 4px 10px rgba(0, 0, 0, 0.08)", + backdropFilter: "blur(20px) saturate(180%)", + }, + black: { + background: "rgba(20, 20, 24, 0.94)", + border: "1.5px solid rgba(255, 255, 255, 0.14)", + boxShadow: + "0 14px 28px rgba(0, 0, 0, 0.55), 0 4px 10px rgba(0, 0, 0, 0.35)", + backdropFilter: "blur(20px) saturate(180%)", + }, + ocean: { + background: "rgba(20, 32, 58, 0.92)", + border: "1.5px solid rgba(96, 165, 250, 0.3)", + boxShadow: + "0 14px 28px rgba(0, 0, 0, 0.45), 0 4px 10px rgba(37, 99, 235, 0.25)", + backdropFilter: "blur(20px) saturate(180%)", + }, + emerald: { + background: "rgba(10, 36, 26, 0.92)", + border: "1.5px solid rgba(52, 211, 153, 0.3)", + boxShadow: + "0 14px 28px rgba(0, 0, 0, 0.45), 0 4px 10px rgba(5, 150, 105, 0.25)", + backdropFilter: "blur(20px) saturate(180%)", + }, + amber: { + background: "rgba(38, 24, 8, 0.92)", + border: "1.5px solid rgba(251, 191, 36, 0.3)", + boxShadow: + "0 14px 28px rgba(0, 0, 0, 0.45), 0 4px 10px rgba(217, 119, 6, 0.25)", + backdropFilter: "blur(20px) saturate(180%)", + }, + rose: { + background: "rgba(38, 14, 22, 0.92)", + border: "1.5px solid rgba(251, 113, 133, 0.3)", + boxShadow: + "0 14px 28px rgba(0, 0, 0, 0.45), 0 4px 10px rgba(225, 29, 72, 0.25)", + backdropFilter: "blur(20px) saturate(180%)", + }, + purple: { + background: "rgba(28, 14, 48, 0.92)", + border: "1.5px solid rgba(167, 139, 250, 0.3)", + boxShadow: + "0 14px 28px rgba(0, 0, 0, 0.45), 0 4px 10px rgba(124, 58, 237, 0.25)", + backdropFilter: "blur(20px) saturate(180%)", + }, +}; + +export interface SingleKeycapProps { + keyText?: string; + label?: string; + isModifier?: boolean; + style?: KeycapStyle; + theme?: KeycapTheme; + scale?: number; + is3D?: boolean; + showChassis?: boolean; + customTextColor?: string; + customBgColor?: string; +} + +export function parseShortcutKeys(text: string): string[] { + if (!text) return []; + if (text.includes("+")) { + return text + .split("+") + .map((s) => s.trim()) + .filter(Boolean); + } + if (text.includes(" ")) { + return text + .split(" ") + .map((s) => s.trim()) + .filter(Boolean); + } + const modifierSymbols = ["⌘", "⌃", "⌥", "⇧"]; + const result: string[] = []; + let remaining = text; + while (remaining.length > 0) { + const matchedModifier = modifierSymbols.find((mod) => + remaining.startsWith(mod), + ); + if (matchedModifier) { + result.push(matchedModifier); + remaining = remaining.slice(matchedModifier.length); + } else { + result.push(remaining); + break; + } + } + return result.filter(Boolean); +} + +export const KeycapUnit = (props: SingleKeycapProps) => SingleKeycap(props); + +export function SingleKeycap(props: SingleKeycapProps) { + const text = () => props.keyText ?? props.label ?? "A"; + const style = () => props.style || "pbt"; + const theme = () => props.theme || "white"; + const scale = () => props.scale || 1.0; + const isMod = () => props.isModifier || false; + const is3D = () => props.is3D ?? true; + + const palette = createMemo(() => { + const themeData = THEME_PALETTES[theme()] || THEME_PALETTES.white; + const base = isMod() ? themeData.mod : themeData.alpha; + const defaultColors = + KEYCAP_THEME_COLORS[theme()] || KEYCAP_THEME_COLORS.white; + const customText = props.customTextColor; + const customBg = props.customBgColor; + const hasCustomText = Boolean( + customText && + customText.trim() !== "" && + customText.toLowerCase() !== defaultColors.text.toLowerCase(), + ); + const hasCustomBg = Boolean( + customBg && + customBg.trim() !== "" && + customBg.toLowerCase() !== defaultColors.background.toLowerCase(), + ); + + if (!hasCustomText && !hasCustomBg) { + return base; + } + + return { + ...base, + ...(hasCustomText && customText ? { textColor: customText } : {}), + ...(hasCustomBg && customBg + ? { + surfaceTop: adjustHex(customBg, 12), + surfaceMid: customBg, + surfaceBottom: adjustHex(customBg, -18), + surfaceStroke: adjustHex(customBg, 20), + dishTop: adjustHex(customBg, 8), + dishBottom: adjustHex(customBg, -8), + } + : {}), + }; + }); + + const instanceId = createUniqueId(); + const uid = () => + `cap_${instanceId}_${style()}_${text().replace(/[^a-zA-Z0-9]/g, "")}_${isMod() ? "m" : "a"}`; + + const displayLabel = () => { + const k = text(); + const lower = k.toLowerCase(); + if ( + lower === "command" || + lower === "cmd" || + lower === "meta" || + lower === "lmeta" || + lower === "rmeta" + ) + return "⌘"; + if ( + lower === "control" || + lower === "ctrl" || + lower === "lcontrol" || + lower === "rcontrol" + ) + return "Ctrl"; + if ( + lower === "alt" || + lower === "option" || + lower === "lalt" || + lower === "ralt" + ) + return "Alt"; + if (lower === "shift" || lower === "lshift" || lower === "rshift") + return "⇧"; + if (lower === "return" || lower === "enter") return "⏎"; + if (lower === "backspace" || lower === "delete") return "⌫"; + if (lower === "escape" || lower === "esc") return "Esc"; + if (lower === "space") return "␣"; + return k.toUpperCase(); + }; + + const renderPBT = () => { + const w = displayLabel().length > 2 ? 88 : 68; + const h = 68; + return ( + + + + + + + + + + + + + + + 2 ? 14 : 19)} + font-weight="600" + > + {displayLabel()} + + + ); + }; + + const renderApple = () => { + const w = displayLabel().length > 2 ? 82 : 62; + const h = 62; + return ( + + + + + + + + + + + + 2 ? 13 : 18)} + font-weight="500" + > + {displayLabel()} + + + ); + }; + + const renderMinimal = () => { + const w = displayLabel().length > 2 ? 76 : 56; + const h = 56; + return ( + + + 2 ? 13 : 18)} + font-weight="600" + > + {displayLabel()} + + + ); + }; + + const renderRetro = () => { + const w = displayLabel().length > 2 ? 86 : 66; + const h = 66; + return ( + + + + + + + 2 ? 14 : 20)} + font-weight="bold" + > + {displayLabel()} + + + ); + }; + + const renderClassicBox = () => { + return ( + + {displayLabel()} + + ); + }; + + return ( + +
+ {renderPBT()} + {renderApple()} + {renderMinimal()} + + {renderRetro()} + +
+
+ ); +} + +export function KeycapPreviewCluster(props: { + keys?: string[]; + style?: KeycapStyle; + theme?: KeycapTheme; + showChassis?: boolean; + use3D?: boolean; + scale?: number; + customTextColor?: string; + customBgColor?: string; +}) { + const keys = () => props.keys || ["⌘", "K"]; + const showChassis = () => props.showChassis ?? true; + const use3D = () => props.use3D ?? true; + const style = () => props.style || "pbt"; + const theme = () => props.theme || "white"; + const scale = () => props.scale || 1.0; + const chassisTheme = () => THEME_CHASSIS[theme()] || THEME_CHASSIS.white; + const chassisBackground = () => { + const customBg = props.customBgColor; + if (customBg && customBg.trim() !== "") { + return customBg.startsWith("#") ? `${customBg}ee` : customBg; + } + return chassisTheme().background; + }; + + return ( +
+ + {(key: string, i: () => number) => ( + 1} + style={style()} + theme={theme()} + scale={scale()} + is3D={use3D()} + showChassis={showChassis()} + customTextColor={props.customTextColor} + customBgColor={props.customBgColor} + /> + )} + +
+ ); +} diff --git a/apps/desktop/src/store/keyboard.ts b/apps/desktop/src/store/keyboard.ts index fa6056dfa17..364824bb02c 100644 --- a/apps/desktop/src/store/keyboard.ts +++ b/apps/desktop/src/store/keyboard.ts @@ -13,14 +13,66 @@ export type KeyboardSettings = { showModifiers: boolean; showSpecialKeys: boolean; uppercase: boolean; + style: KeycapStyle; + theme: KeycapTheme; + keycapMode: boolean; + showChassis: boolean; +}; + +export type KeycapStyle = + | "pbt" + | "apple" + | "retro" + | "minimal" + | "m0116" + | "classic_box"; +export type KeycapTheme = + | "white" + | "black" + | "ocean" + | "emerald" + | "amber" + | "rose" + | "purple"; + +export const KEYCAP_STYLE_OPTIONS: { label: string; value: KeycapStyle }[] = [ + { label: "PBT Mechanical (3D)", value: "pbt" }, + { label: "Apple Modern", value: "apple" }, + { label: "Minimal Pill", value: "minimal" }, + { label: "Retro Vintage", value: "retro" }, + { label: "Apple M0116 Vintage", value: "m0116" }, + { label: "Classic Subtitle Box", value: "classic_box" }, +]; + +export const KEYCAP_THEME_OPTIONS: { label: string; value: KeycapTheme }[] = [ + { label: "Pure White", value: "white" }, + { label: "Stealth Dark", value: "black" }, + { label: "Ocean Blue", value: "ocean" }, + { label: "Emerald Green", value: "emerald" }, + { label: "Amber Orange", value: "amber" }, + { label: "Rose Quartz", value: "rose" }, + { label: "Deep Purple", value: "purple" }, +]; + +export const KEYCAP_THEME_COLORS: Record< + KeycapTheme, + { text: string; background: string } +> = { + white: { text: "#1A1A1A", background: "#EFEFEF" }, + black: { text: "#F5F5F7", background: "#1A1A1E" }, + ocean: { text: "#FFFFFF", background: "#2563EB" }, + emerald: { text: "#FFFFFF", background: "#059669" }, + amber: { text: "#FFFFFF", background: "#D97706" }, + rose: { text: "#FFFFFF", background: "#E11D48" }, + purple: { text: "#FFFFFF", background: "#7C3AED" }, }; export const defaultKeyboardSettings: KeyboardSettings = { enabled: false, font: "System Sans-Serif", size: 50, - color: "#FFFFFF", - backgroundColor: "#000000", + color: "#1A1A1A", + backgroundColor: "#EFEFEF", backgroundOpacity: 95, position: "bottom-center", fontWeight: 400, @@ -30,4 +82,8 @@ export const defaultKeyboardSettings: KeyboardSettings = { showModifiers: true, showSpecialKeys: true, uppercase: false, + style: "pbt", + theme: "white", + keycapMode: true, + showChassis: true, }; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 6f27d9856cb..d8a088ea6f4 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -1135,7 +1135,7 @@ export type InstantRecordingMeta = { recording: boolean } | { error: string } | export type JsonValue = [T] export type KeyPressDisplay = { key: string; timeOffset: number } export type KeyboardData = { settings: KeyboardSettings } -export type KeyboardSettings = { enabled: boolean; font: string; size: number; color: string; backgroundColor: string; backgroundOpacity: number; position: string; fontWeight: number; fadeDuration: number; lingerDuration: number; groupingThresholdMs: number; showModifiers: boolean; showSpecialKeys: boolean; uppercase: boolean } +export type KeyboardSettings = { enabled: boolean; font: string; size: number; color: string; backgroundColor: string; backgroundOpacity: number; position: string; fontWeight: number; fadeDuration: number; lingerDuration: number; groupingThresholdMs: number; showModifiers: boolean; showSpecialKeys: boolean; uppercase: boolean; style: string; theme: string; keycapMode: boolean; showChassis: boolean } export type KeyboardTrackSegment = { id: string; start: number; end: number; displayText: string; keys?: KeyPressDisplay[]; fadeDurationOverride?: number | null; positionOverride?: string | null; colorOverride?: string | null; backgroundColorOverride?: string | null; fontSizeOverride?: number | null; uppercaseOverride?: boolean | null } export type LogicalBounds = { position: LogicalPosition; size: LogicalSize } export type LogicalPosition = { x: number; y: number } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 4e107005f4d..e112995df5d 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2383,6 +2383,10 @@ pub struct KeyboardSettings { pub show_modifiers: bool, pub show_special_keys: bool, pub uppercase: bool, + pub style: String, + pub theme: String, + pub keycap_mode: bool, + pub show_chassis: bool, } impl Default for KeyboardSettings { @@ -2391,8 +2395,8 @@ impl Default for KeyboardSettings { enabled: false, font: "System Sans-Serif".to_string(), size: 50, - color: "#FFFFFF".to_string(), - background_color: "#000000".to_string(), + color: "#1A1A1A".to_string(), + background_color: "#EFEFEF".to_string(), background_opacity: 95, position: "bottom-center".to_string(), font_weight: 400, @@ -2402,6 +2406,10 @@ impl Default for KeyboardSettings { show_modifiers: true, show_special_keys: true, uppercase: false, + style: "pbt".to_string(), + theme: "white".to_string(), + keycap_mode: true, + show_chassis: true, } } } diff --git a/crates/rendering/src/layers/keyboard.rs b/crates/rendering/src/layers/keyboard.rs index a690adf3f93..20a14a22acd 100644 --- a/crates/rendering/src/layers/keyboard.rs +++ b/crates/rendering/src/layers/keyboard.rs @@ -47,8 +47,8 @@ impl KeyboardPosition { fn y_factor(&self) -> f32 { match self { Self::TopLeft | Self::TopCenter | Self::TopRight => 0.08, - Self::AboveCaptions => 0.75, - Self::BottomLeft | Self::BottomCenter | Self::BottomRight => 0.85, + Self::AboveCaptions => 0.72, + Self::BottomLeft | Self::BottomCenter | Self::BottomRight => 0.74, } } } @@ -371,11 +371,36 @@ impl KeyboardLayer { let margin = width as f32 * 0.05; + let (theme_bg_hex, theme_text_hex) = match settings.theme.as_str() { + "black" | "dark" => ("#1A1A1E", "#F5F5F7"), + "retro_beige" | "retro" => ("#DCD6C8", "#2A2A2A"), + "white" => ("#EFEFEF", "#1A1A1A"), + "ocean" => ("#2563EB", "#FFFFFF"), + "emerald" => ("#059669", "#FFFFFF"), + "amber" => ("#D97706", "#FFFFFF"), + "rose" => ("#E11D48", "#FFFFFF"), + "purple" => ("#7C3AED", "#FFFFFF"), + "cyberpunk" => ("#0D0D15", "#00FFA3"), + _ => ("#EFEFEF", "#1A1A1A"), + }; + + let fallback_bg = if !settings.background_color.is_empty() { + settings.background_color.as_str() + } else { + theme_bg_hex + }; + + let fallback_text = if !settings.color.is_empty() { + settings.color.as_str() + } else { + theme_text_hex + }; + let color_hex = active .segment .color_override .as_deref() - .unwrap_or(&settings.color); + .unwrap_or(fallback_text); let text_color = [ parse_color_component(color_hex, 0), parse_color_component(color_hex, 1), @@ -386,15 +411,18 @@ impl KeyboardLayer { .segment .background_color_override .as_deref() - .unwrap_or(&settings.background_color); + .unwrap_or(fallback_bg); let background_color_rgb = [ parse_color_component(bg_color_hex, 0), parse_color_component(bg_color_hex, 1), parse_color_component(bg_color_hex, 2), ]; - let background_alpha = - ((settings.background_opacity as f32 / 100.0) * fade_opacity).clamp(0.0, 1.0); + let background_alpha = if settings.keycap_mode && !settings.show_chassis { + 0.0 + } else { + ((settings.background_opacity as f32 / 100.0) * fade_opacity).clamp(0.0, 1.0) + }; let font_size_base = active.segment.font_size_override.unwrap_or(settings.size) as f32; let font_size = font_size_base * (height as f32 / 1080.0); @@ -447,8 +475,19 @@ impl KeyboardLayer { } let available_width = (width as f32 - margin * 2.0).max(1.0); - let padding = font_size * 0.45; - let corner_radius = font_size * 0.5; + let padding = if settings.keycap_mode { + font_size * 0.55 + } else { + font_size * 0.45 + }; + + let corner_radius = match settings.style.as_str() { + "minimal" => font_size * 0.9, + "apple" => font_size * 0.55, + "pbt" => font_size * 0.45, + "retro" | "m0116" => font_size * 0.35, + _ => font_size * 0.5, + }; let text_width = layout_width.min(available_width); let text_height = layout_height; let box_width = (text_width + padding * 2.0).min(available_width).max(1.0);