Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,20 @@ if(KEYFLOW_HAS_PLATFORM_BACKEND)

add_executable(keyflow ${SOURCES})

# Expose the CMake project version to the C++ source so `--version` can print
# the same number the release workflow tags with.
target_compile_definitions(keyflow PRIVATE
KEYFLOW_VERSION="${PROJECT_VERSION}"
)

if(WIN32)
target_link_libraries(keyflow PRIVATE interception)

# Release builds: no console window. Process lives in the system tray.
# /ENTRY:mainCRTStartup lets us keep the standard `int main(argc, argv)`
# signature instead of switching to WinMain.
# Generator expressions are required: Visual Studio is multi-config,
# so CMAKE_BUILD_TYPE is empty at configure time.
if(MSVC)
target_link_options(keyflow PRIVATE
$<$<CONFIG:Release>:/SUBSYSTEM:WINDOWS>
$<$<CONFIG:Release>:/ENTRY:mainCRTStartup>
)
endif()
endif()

# Output directory - organize by build type
Expand Down
110 changes: 110 additions & 0 deletions designer/app/screenshot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useEffect, useState } from "react";
import {
ExportKeyboard,
buildKeyContexts,
type LayerFilter,
} from "@/components/Export/ExportKeyboard";
import type { KeyFlowConfig } from "@/lib/types";

// Renderer for documentation screenshots. Read by the Playwright capture spec
// (designer/tests/capture-images.spec.ts) which iterates over ?layer= values
// and snapshots the #screenshot-root element to docs/images/.
//
// Why fetch instead of import: Turbopack refuses cross-root imports, so the
// bundled config can't be `import`ed from ../../src/. A `predev` / `prebuild`
// npm hook copies src/config.json into designer/public/eduuh-dh-config.json
// (see scripts/copy-config.mjs), and this page fetches that static asset.
//
// Why window.location instead of useSearchParams: useSearchParams in the App
// Router requires a Suspense boundary in static-export mode, and without it
// the route silently bails out of prerender. Reading window.location.search
// in useEffect avoids the Suspense requirement entirely since the whole page
// only matters client-side anyway.
//
// URL params:
// ?layer=all → BASE + every layer mapping stacked per keycap (composite)
// ?layer=base → BASE remap only
// ?layer=0 → BASE + layer 0 (Numpad for Eduuh-DH)
// ?layer=1 → BASE + layer 1 (Arrows & Symbols)

function parseLayerParam(raw: string | null): LayerFilter {
if (raw === "base") return "base";
const idx = Number(raw);
if (Number.isInteger(idx) && idx >= 0) return idx;
return "all";
}

export default function ScreenshotPage() {
const [layerFilter, setLayerFilter] = useState<LayerFilter>("all");
const [config, setConfig] = useState<KeyFlowConfig | null>(null);

useEffect(() => {
// Force dark theme so screenshots don't depend on the visitor's persisted
// ThemeProvider preference (which lives in localStorage).
const root = document.documentElement;
root.classList.remove("light");
root.classList.add("dark");

setLayerFilter(parseLayerParam(new URLSearchParams(window.location.search).get("layer")));

// Fetch the bundled Eduuh-DH config. Absolute path because relative would
// resolve under /screenshot/ and 404 against the public asset at /.
// Note: this route is intended for local capture (Playwright + npm run
// capture-images), not for production viewing. On GitHub Pages with the
// /keyflow basePath, the absolute path would 404 — a separate concern.
fetch("/eduuh-dh-config.json")
.then((r) => r.json())
.then((c) => setConfig(c as KeyFlowConfig))
.catch((err) => {
console.error("Failed to load eduuh-dh-config.json", err);
});
}, []);

if (!config) {
// Render nothing until the config is loaded — keeps the
// [data-screenshot-ready] selector from matching before the keyboard is real.
return <div style={{ background: "#0a0a0a", minHeight: "100vh" }} />;
}

const keyContexts = buildKeyContexts(config);

return (
<div
style={{
background:
"radial-gradient(ellipse at top, #161B2A 0%, #0B0E16 60%, #060810 100%)",
padding: "32px 24px",
minHeight: "100vh",
display: "flex",
alignItems: "flex-start",
justifyContent: "flex-start",
}}
>
<div
id="screenshot-root"
data-screenshot-ready
style={{
// Subtle mounting-plate frame so the keyboard reads as a unit
// rather than floating cells on bare background.
padding: "20px",
background:
"linear-gradient(180deg, #1A1F2C 0%, #131724 100%)",
borderRadius: "14px",
boxShadow:
"inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 60px -20px rgba(0,0,0,0.6)",
border: "1px solid rgba(255,255,255,0.04)",
}}
>
<ExportKeyboard
keyContexts={keyContexts}
isHighlighted={() => false}
config={config}
scale={80}
layerFilter={layerFilter}
/>
</div>
</div>
);
}
175 changes: 6 additions & 169 deletions designer/components/Export/CategoryExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ansiLayout } from "@/lib/keyboardLayout";
import { getKeyType } from "@/lib/keyTypes";
import {
ExportKeyboard,
buildKeyContexts,
type KeyContext,
} from "@/components/Export/ExportKeyboard";

// Category-filtered keyboard exports.
//
Expand All @@ -40,12 +43,6 @@ type ExportCategory = {
match: (ctx: KeyContext) => boolean;
};

type KeyContext = {
physicalKey: string;
/** Every value this physical key can produce: base + layer outputs + shift outputs. */
outputs: string[];
};

const CATEGORIES: ExportCategory[] = [
{ id: "layout", label: "Full layout", match: () => true },
{
Expand Down Expand Up @@ -107,20 +104,7 @@ export function CategoryExport({ isOpen, onClose }: CategoryExportProps) {
const exportRef = useRef<HTMLDivElement>(null);

// Build a context for every key in the layout so categories can be matched.
const keyContexts = new Map<string, KeyContext>();
ansiLayout.forEach((k) => {
const outputs: string[] = [];
const base = config.remapping?.[k.keyCode];
if (base) outputs.push(base);
config.layers?.forEach((layer) => {
const m = layer.mappings?.[k.keyCode];
if (m) outputs.push(m);
layer.shiftMappings?.forEach((sm) => {
if (sm.key === k.keyCode) outputs.push(sm.output);
});
});
keyContexts.set(k.keyCode, { physicalKey: k.keyCode, outputs });
});
const keyContexts = buildKeyContexts(config);

const current = CATEGORIES.find((c) => c.id === activeCategory) ?? CATEGORIES[0];

Expand Down Expand Up @@ -249,150 +233,3 @@ export function CategoryExport({ isOpen, onClose }: CategoryExportProps) {
</Dialog>
);
}

// ─── The render target for export ──────────────────────────────────────────

type ExportKeyboardProps = {
keyContexts: Map<string, KeyContext>;
isHighlighted: (keyCode: string) => boolean;
config: ReturnType<typeof useConfigStore.getState>["config"];
};

function ExportKeyboard({ keyContexts, isHighlighted, config }: ExportKeyboardProps) {
const scale = 64;
const width = 15 * scale;
const height = 5 * scale;

return (
<div
className="relative font-mono"
style={{ width: `${width}px`, height: `${height}px` }}
>
{ansiLayout.map((keyData) => {
const highlighted = isHighlighted(keyData.keyCode);
const ctx = keyContexts.get(keyData.keyCode);
const labels = buildLabels(keyData, config);
const keyType = getKeyType(
// Use the first layer output for type, or fall back to base or physical
ctx?.outputs[0] ?? keyData.keyCode,
);
const _ = keyType; // keep type info available; visual is binary highlighted vs not

return (
<div
key={keyData.id}
className="absolute rounded-md flex flex-col items-stretch justify-between p-1 overflow-hidden text-[10px] leading-tight"
style={{
left: `${keyData.x * scale}px`,
top: `${keyData.y * scale}px`,
width: `${keyData.width * scale - 4}px`,
height: `${keyData.height * scale - 4}px`,
background: highlighted ? "#5DADE2" : "#2D3540",
color: highlighted ? "#0a0a0a" : "#fff",
border: `1px solid ${highlighted ? "#7AC0E8" : "#1c2128"}`,
}}
>
{/* Top: layer outputs (if any) */}
<div className="flex justify-between gap-0.5">
<span
className="opacity-90 truncate font-semibold"
style={{ fontSize: "10px" }}
>
{labels.topLeft || ""}
</span>
<span
className="opacity-90 truncate text-right"
style={{ fontSize: "9px" }}
>
{labels.topRight || ""}
</span>
</div>
{/* Bottom: physical key + base output */}
<div className="flex justify-between items-end gap-0.5">
<span className="truncate font-semibold" style={{ fontSize: "12px" }}>
{labels.bottomLeft || ""}
</span>
<span className="truncate opacity-70" style={{ fontSize: "9px" }}>
{labels.bottomRight || ""}
</span>
</div>
</div>
);
})}
</div>
);
}

type KeyLabels = {
topLeft?: string; // First layer output (e.g. RAlt layer)
topRight?: string; // Second layer output (e.g. LAlt layer)
bottomLeft: string; // Base remap output (or physical label if no remap)
bottomRight?: string; // Shift mapping output (e.g. ⇧+)
};

function buildLabels(
keyData: (typeof ansiLayout)[number],
config: ReturnType<typeof useConfigStore.getState>["config"],
): KeyLabels {
const baseMapping = config.remapping?.[keyData.keyCode];
const layers = config.layers ?? [];

const layer0Output = layers[0]?.mappings?.[keyData.keyCode];
const layer1Output = layers[1]?.mappings?.[keyData.keyCode];

// Show a shift mapping from any layer (prefer layer 1's, fall back to layer 0)
const shiftFromLayer1 = layers[1]?.shiftMappings?.find((m) => m.key === keyData.keyCode);
const shiftFromLayer0 = layers[0]?.shiftMappings?.find((m) => m.key === keyData.keyCode);
const shiftMapping = shiftFromLayer1 || shiftFromLayer0;

return {
topLeft: layer0Output ? compactLabel(layer0Output) : undefined,
topRight: layer1Output
? compactLabel(layer1Output)
: shiftMapping
? `⇧${compactLabel(shiftMapping.output)}`
: undefined,
bottomLeft: baseMapping ? compactLabel(baseMapping) : compactLabel(keyData.label),
bottomRight: undefined, // Reserved
};
}

// Shrink long key names so they fit on a 60-px-wide keycap face.
function compactLabel(label: string): string {
const short: Record<string, string> = {
LeftShift: "L⇧",
RightShift: "R⇧",
LeftCtrl: "L⌃",
RightCtrl: "R⌃",
LeftAlt: "L⌥",
RightAlt: "R⌥",
LeftWin: "L⊞",
RightWin: "R⊞",
CapsLock: "Caps",
Backspace: "⌫",
Enter: "↵",
Escape: "Esc",
PageUp: "PgUp",
PageDown: "PgDn",
Insert: "Ins",
Delete: "Del",
PrintScreen: "PrtSc",
Up: "↑",
Down: "↓",
Left: "←",
Right: "→",
Grave: "`",
Minus: "-",
Equals: "=",
LeftBracket: "[",
RightBracket: "]",
Backslash: "\\",
Semicolon: ";",
Apostrophe: "'",
Comma: ",",
Period: ".",
Slash: "/",
Space: "␣",
};
return short[label] || label;
}
Loading
Loading