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
124 changes: 123 additions & 1 deletion apps/probe-viewer/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@
color: var(--ink);
}

/* "{ } JSON" link inline in the subtitle metadata line. It uses the blue link
/* "JSON" link inline in the subtitle metadata line. It uses the blue link
color and a persistent underline (on the word, not the icon) so it clearly
reads as a clickable link rather than just emphasized text. */
.viewer-json-link {
Expand All @@ -314,6 +314,27 @@
color: var(--accent-strong);
}

.viewer-json-download {
display: inline-flex;
align-items: center;
vertical-align: middle;
margin-left: 0.35rem;
padding: 0.15rem;
border-radius: 0.3rem;
color: var(--accent);
transition: color 0.2s ease, background 0.2s ease;
}

.viewer-json-download svg {
width: 14px;
height: 14px;
}

.viewer-json-download:hover {
color: var(--accent-strong);
background: var(--accent-soft);
}

.viewer-subtitle {
margin: 0.25rem 0 0;
color: #475569;
Expand Down Expand Up @@ -566,6 +587,107 @@
background: var(--accent-soft);
}

.viewer-snippet {
position: relative;
display: inline-flex;
vertical-align: middle;
}

/* Looks like the JSON link, with a chevron to say it opens something. */
.viewer-snippet-toggle {
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0;
border: none;
background: none;
font: inherit;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}

.viewer-snippet-toggle:hover {
color: var(--accent-strong);
}

.viewer-snippet-popover {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 22rem;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}

/* Language label on the left, copy action on the right, as in docs code blocks. */
.viewer-snippet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.3rem 0.4rem 0.3rem 0.8rem;
border-bottom: 1px solid var(--line);
background: var(--bg);
}

.viewer-snippet-language {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}

.viewer-snippet-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.45rem;
border: none;
border-radius: 0.3rem;
background: none;
font: inherit;
font-size: 0.78rem;
color: var(--ink-2);
cursor: pointer;
}

.viewer-snippet-copy:hover {
color: var(--accent-strong);
background: var(--accent-soft);
}

.viewer-snippet-code {
display: block;
padding: 0.8rem 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.5;
color: var(--ink-2);
background: #f8fafc;
white-space: pre;
}

/* Syntax highlighting kept monochrome: the chrome carries no hue (see :root). */
.viewer-snippet-token--keyword {
font-weight: 700;
color: var(--accent-strong);
}

.viewer-snippet-token--function {
color: var(--ink);
}

.viewer-snippet-token--string {
color: var(--muted);
}

.viewer-canvas-placeholder {
height: 360px;
border-radius: 1rem;
Expand Down
22 changes: 13 additions & 9 deletions apps/probe-viewer/src/components/ProbeViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ProbeCanvas } from "./ProbeCanvas";
import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas";
import { LocalProbePanel } from "./LocalProbePanel";
import { ProbeOverview } from "./ProbeOverview";
import { PythonSnippet } from "./PythonSnippet";

const CANVAS_PADDING = 40;
// How far past "the smallest contact exactly fills the viewport" the zoom cap
Expand Down Expand Up @@ -131,14 +132,6 @@ const DownloadIcon = (
</svg>
);

// Curly-braces glyph, the de-facto standard symbol for JSON/code.
const JsonIcon = (
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/>
<path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>
</svg>
);

// Leading icons for the view-toggle chips: an eye (show/hide contact IDs), an
// I-beam matching the on-canvas scale bar, and a minimap frame for the overview.
const EyeIcon = (
Expand Down Expand Up @@ -391,9 +384,20 @@ export function ProbeViewer() {
rel="noreferrer"
title="View this probe's JSON on GitHub"
>
{JsonIcon}
<span className="viewer-json-link-text">JSON</span>
</a>
<a
className="viewer-json-download"
href={entry.jsonUrl}
download={`${entry.model}.json`}
title="Download JSON"
aria-label="Download JSON"
>
{DownloadIcon}
</a>
{" "}
·{" "}
<PythonSnippet manufacturer={entry.manufacturer} model={entry.model} />
</>
)}
</p>
Expand Down
111 changes: 111 additions & 0 deletions apps/probe-viewer/src/components/PythonSnippet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from "react";

const ChevronDownIcon = (
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);

const CopyIcon = (
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
);

const CheckIcon = (
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);

interface PythonSnippetProps {
manufacturer: string;
model: string;
}

// "Python" toggle for the probe subtitle. It opens a popover with the
// probeinterface code that loads this probe from the library.
export function PythonSnippet({ manufacturer, model }: PythonSnippetProps) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const ref = useRef<HTMLSpanElement>(null);

// The snippet as (token kind, text) pairs, so the highlighted markup and the
// copied text come from the same source.
const tokens: [string | null, string][] = [
["keyword", "from"],
[null, " probeinterface "],
["keyword", "import"],
[null, " get_probe\n\nprobe = "],
["function", "get_probe"],
[null, "("],
["string", `"${manufacturer}"`],
[null, ", "],
["string", `"${model}"`],
[null, ")"],
];
const code = tokens.map(([, text]) => text).join("");

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [code]);

// Close on a click outside the popover or on Escape.
useEffect(() => {
if (!open) return;
const handleMouseDown = (event: MouseEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);

return (
<span className="viewer-snippet" ref={ref}>
<button
type="button"
className="viewer-snippet-toggle"
onClick={() => setOpen((value) => !value)}
title="Show how to load this probe with probeinterface"
aria-expanded={open}
>
<span className="viewer-json-link-text">Python</span>
{ChevronDownIcon}
</button>
{open && (
<span className="viewer-snippet-popover">
<span className="viewer-snippet-header">
<span className="viewer-snippet-language">Python</span>
<button
type="button"
className="viewer-snippet-copy"
onClick={handleCopy}
aria-label="Copy code"
>
{copied ? CheckIcon : CopyIcon}
{copied ? "Copied!" : "Copy"}
</button>
</span>
<code className="viewer-snippet-code">
{tokens.map(([kind, text], index) => (
<span key={index} className={kind ? `viewer-snippet-token--${kind}` : undefined}>
{text}
</span>
))}
</code>
</span>
)}
</span>
);
}
Loading