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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

- **Domain types are plain nouns.** `Glyph`, `Contour`, `Point`, `Anchor` — not `Glyph`, `GlyphInfo`, `GlyphState`, `GlyphRenderData`. If you need a modifier, it should describe the _kind_ of thing (`EditableGlyph`, `RenderContour`), not append generic suffixes.
- **Avoid `-Data`, `-Info`, `-State` suffixes** on types unless it genuinely represents transient mutable state (e.g. `TextRunRenderState` for a signal value consumed by a render pass). If the type represents a domain concept, name it after the concept.
- **Signals are named `*Cell`; values use the plain noun.** A `Signal<Glyph | null>` is `glyphCell`, and the unwrapped value is `glyph`. Do not introduce new dollar-prefixed signal names; those are legacy style.
- **Use `track(cell)` for invalidation-only subscriptions.** Inside `computed`/`effect`, use `track(fooCell)` when the code needs to rerun on `fooCell` changes but does not use the value. Use `const foo = fooCell.value` when the value is actually read.

## Roadmap

Expand Down Expand Up @@ -161,7 +163,7 @@ Rules enforced by `scripts/oxlint/shift-plugin.mjs` are omitted here — the lin

**NEVER call `bridge.setNodePositions()` inside the draft hot path.** That sends N individual NAPI struct marshals to Rust per frame. For glyphs with thousands of points this causes ~450ms frames + GC pressure. The draft exists specifically to avoid this.

**Render effects track `glyph.contours` and `glyph.anchors`**, not `$glyph`. The `$glyph` signal on NativeBridge is for glyph identity (loaded/unloaded). Glyph data changes propagate through the Glyph model's internal signals. `#patchPositions` fires `#contours` with a new array reference so glyph-level effects see the change.
**Render effects track `glyph.contours` and `glyph.anchors`**, not a bridge glyph identity cell. Bridge glyph identity signals are for loaded/unloaded identity changes. Glyph data changes propagate through the Glyph model's internal signals. `#patchPositions` fires the contour cell with a new array reference so glyph-level effects see the change.

## Documentation Routing

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/app/Screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const WorkspaceScreens = () => {
const workspace = useWorkspace();
const font = useFont();
const editor = useEditor();
const documentLoaded = useSignalState(font.$loaded);
const documentLoaded = useSignalState(font.loadedCell);
const [connectionError, setConnectionError] = useState<unknown>(null);

useEffect(() => {
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,21 @@ function formatBytes(bytes: number): string {

export function DebugPanel() {
const editor = useEditor();
const instance = useSignalState(editor.glyphInstanceCell);
const instance = useSignalState(editor.previewGlyphInstanceCell);
const layer = useSignalState(editor.editingGlyphLayerCell);
useSignalTrigger(instance?.xAdvanceCell);

const glyphStats = useMemo(() => {
if (!instance) return { pointCount: "0", snapshotSize: "—" };

const snapshot = instance.layer?.state ?? null;
const snapshot = layer?.state ?? null;
const bytes = snapshot ? new Blob([JSON.stringify(snapshot)]).size : null;

return {
pointCount: `${instance.geometry.allPoints.length}`,
snapshotSize: bytes === null ? "—" : formatBytes(bytes),
};
}, [instance]);
}, [instance, layer]);

useEffect(() => {
editor.startFpsMonitor();
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/components/editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const Canvas: FC = () => {
}

const glyphId = asGlyphId(glyphIdParam);
if (!editor.font.recordForId(glyphId)) {
if (!editor.font.glyph(glyphId)) {
editor.scene.clear();
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const AnchorSection = () => {
const [singleAnchorId, setSingleAnchorId] = useState<AnchorId | null>(null);

const [anchorName, setAnchorName] = useState<string | null>(null);
const [hasLayer, setHasLayer] = useState(false);
const [editable, setEditable] = useState(false);

const [anchorX, setAnchorX] = useState(0);
const [anchorY, setAnchorY] = useState(0);
Expand All @@ -21,10 +21,11 @@ export const AnchorSection = () => {
const yRef = useRef<EditableSidebarInputHandle>(null);

useSignalEffect(() => {
const instance = editor.glyphInstanceCell.value;
const instance = editor.previewGlyphInstanceCell.value;
const layer = editor.editingGlyphLayerCell.value;
const ids = [...editor.selection.stateCell.value.anchorIds];

setHasLayer(instance?.hasLayer ?? false);
setEditable(layer !== null);

if (!instance || ids.length !== 1) {
setSingleAnchorId(null);
Expand Down Expand Up @@ -54,7 +55,7 @@ export const AnchorSection = () => {
const handlePositionChange = (axis: "x" | "y", value: number) => {
if (!singleAnchorId) return;

const layer = editor.glyphInstanceCell.peek()?.layer;
const layer = editor.editingGlyphLayer;
if (!layer) return;

const nextX = axis === "x" ? value : anchorX;
Expand All @@ -69,13 +70,13 @@ export const AnchorSection = () => {
<EditableSidebarInput
ref={xRef}
label="X"
disabled={singleAnchorId === null || !hasLayer}
disabled={singleAnchorId === null || !editable}
onValueChange={(value) => handlePositionChange("x", value)}
/>
<EditableSidebarInput
ref={yRef}
label="Y"
disabled={singleAnchorId === null || !hasLayer}
disabled={singleAnchorId === null || !editable}
onValueChange={(value) => handlePositionChange("y", value)}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import { useGlyphXAdvance } from "@/hooks/useGlyphXAdvance";

export const GlyphSection = () => {
const editor = useEditor();
const glyph = useSignalState(editor.glyph);
const glyph = useSignalState(editor.previewGlyphRecordCell);
const sidebearings = useGlyphSidebearings();
const xAdvance = useGlyphXAdvance();
const glyphInfo = getGlyphInfo();

const unicode = formatCodepointAsUPlus(glyph?.unicode ?? 0);
const unicodeValue = glyph?.unicodes[0] ?? 0;
const unicode = formatCodepointAsUPlus(unicodeValue);
const lsb =
sidebearings.sidebearings.lsb === null ? null : Math.round(sidebearings.sidebearings.lsb);
const rsb =
sidebearings.sidebearings.rsb === null ? null : Math.round(sidebearings.sidebearings.rsb);
const sidebearingsEnabled = sidebearings.hasLayer && lsb !== null && rsb !== null;
const sidebearingsEnabled = sidebearings.editable && lsb !== null && rsb !== null;

return (
<SidebarSection title="Glyph">
Expand Down Expand Up @@ -52,11 +53,11 @@ export const GlyphSection = () => {
<EditableSidebarInput
className="text-center"
value={Math.round(xAdvance.xAdvance)}
disabled={!xAdvance.hasLayer}
disabled={!xAdvance.editable}
onValueChange={(width) => editor.setXAdvance(width)}
/>
</div>
<div className="font-sans mt-2 text-sm">{glyphInfo.getGlyphName(glyph?.unicode ?? 0)}</div>
<div className="font-sans mt-2 text-sm">{glyphInfo.getGlyphName(unicodeValue)}</div>
</main>
</SidebarSection>
);
Expand Down
23 changes: 13 additions & 10 deletions apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ import { getGlyphInfo } from "@/workspace/glyphInfo";
import { type GlyphCatalogItem, useGlyphCatalog } from "@/context/GlyphCatalogContext";
import { Button, Input } from "@shift/ui";
import type { GlyphId, GlyphName } from "@shift/types";
import type { Glyph } from "@/lib/model/Glyph";

const ROW_HEIGHT = CELL_HEIGHT + 40 + 8;
const NOMINAL_CELL_WIDTH = 100;
Expand Down Expand Up @@ -87,7 +86,7 @@ export const GlyphGrid = memo(function GlyphGrid() {
const editor = useEditor();
const font = editor.font;
const metrics = font.metrics;
const designLocation = editor.$designLocation;
const designLocation = editor.designLocationCell;
const { filteredGlyphs: glyphs } = useGlyphCatalog();

const scrollContainerRef = useRef<HTMLDivElement>(null);
Expand All @@ -97,7 +96,10 @@ export const GlyphGrid = memo(function GlyphGrid() {
// is the single source for that. We avoid a sync width read on mount so layout doesn't jitter when
// the container isn't laid out yet or when navigating back. We only set state when the computed
// layout (columns, cellWidth) actually changes.
const [layout, setLayout] = useState(() => ({ columns: 1, cellWidth: NOMINAL_CELL_WIDTH }));
const [layout, setLayout] = useState(() => ({
columns: 1,
cellWidth: NOMINAL_CELL_WIDTH,
}));

useEffect(() => {
const container = scrollContainerRef.current;
Expand Down Expand Up @@ -130,18 +132,18 @@ export const GlyphGrid = memo(function GlyphGrid() {
() => visibleGlyphIdsForRows(glyphs, columns, virtualRows),
[glyphs, columns, visibleRowsKey],
);
const [loadedGlyphs, setLoadedGlyphs] = useState<ReadonlyMap<GlyphId, Glyph>>(() => new Map());
const [loadedGlyphIds, setLoadedGlyphIds] = useState<ReadonlySet<GlyphId>>(() => new Set());

useEffect(() => {
let cancelled = false;

async function load(): Promise<void> {
try {
const loaded = await font.loadGlyphs(visibleGlyphIds);
if (!cancelled) setLoadedGlyphs(loaded);
if (!cancelled) setLoadedGlyphIds(loaded);
} catch (error) {
console.error("failed to load visible glyphs", error);
if (!cancelled) setLoadedGlyphs(new Map());
if (!cancelled) setLoadedGlyphIds(new Set());
}
}

Expand All @@ -155,8 +157,8 @@ export const GlyphGrid = memo(function GlyphGrid() {
const handleCellClick = useCallback(
async (glyph: GlyphCatalogItem) => {
try {
const loadedGlyph = await font.loadGlyph(glyph.id);
if (!loadedGlyph) return;
const loaded = await font.loadGlyph(glyph.id);
if (!loaded) return;

navigate(`/editor/${encodeURIComponent(glyph.id)}`);
} catch (error) {
Expand Down Expand Up @@ -199,7 +201,7 @@ export const GlyphGrid = memo(function GlyphGrid() {
className="flex gap-2 px-4"
>
{rowGlyphs.map((glyph) => {
const previewGlyph = loadedGlyphs.get(glyph.id) ?? null;
const previewGlyphId = loadedGlyphIds.has(glyph.id) ? glyph.id : null;
return (
<div
key={glyph.id}
Expand All @@ -217,7 +219,8 @@ export const GlyphGrid = memo(function GlyphGrid() {
onClick={() => handleCellClick(glyph)}
>
<GlyphPreview
glyph={previewGlyph}
font={font}
glyphId={previewGlyphId}
unicode={glyph.unicode}
metrics={metrics}
designLocation={designLocation}
Expand Down
41 changes: 21 additions & 20 deletions apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FontMetrics } from "@shift/types";
import type { Glyph } from "@/lib/model/Glyph";
import { type Signal, useSignalState } from "@/lib/signals";
import type { FontMetrics, GlyphId } from "@shift/types";
import type { Font } from "@/lib/model/Font";
import { type Signal, useSignalTrigger } from "@/lib/signals";
import type { AxisLocation } from "@/types/variation";

export const CELL_HEIGHT = 75;
Expand Down Expand Up @@ -43,65 +43,66 @@ export function computeCellWidth(
}

interface GlyphPreviewProps {
glyph: Glyph | null;
font: Font;
glyphId: GlyphId | null;
unicode: number | null;
metrics: FontMetrics;
designLocation: Signal<AxisLocation>;
height?: number;
}

export function GlyphPreview({
glyph,
font,
glyphId,
unicode,
metrics,
designLocation,
height = CELL_HEIGHT,
}: GlyphPreviewProps) {
if (!glyph) {
if (!glyphId) {
return <FallbackCell metrics={metrics} height={height} unicode={unicode} advance={null} />;
}

return (
<GlyphCell
font={font}
metrics={metrics}
height={height}
glyph={glyph}
glyphId={glyphId}
unicode={unicode}
designLocation={designLocation}
/>
);
}

function GlyphCell({
font,
metrics,
height,
glyph,
glyphId,
unicode,
designLocation,
}: {
font: Font;
metrics: FontMetrics;
height: number;
glyph: Glyph;
glyphId: GlyphId;
unicode: number | null;
designLocation: Signal<AxisLocation>;
}) {
const outline = glyph.instance(designLocation).render.outline;
const instance = font.instance(glyphId, designLocation);
const outline = instance?.render.outline ?? null;

const svgPath = useSignalState(outline.$svgPath);
const advance = useSignalState(glyph.$xAdvance);
useSignalTrigger(outline?.svgPathCell);
useSignalTrigger(instance?.xAdvanceCell);
const svgPath = outline?.svgPath ?? "";
const advance = instance?.xAdvance ?? null;

const cellWidth = computeCellWidth(metrics, advance, height);
const containerStyle = { width: cellWidth, height };

if (!svgPath) {
return (
<FallbackCell
metrics={metrics}
height={height}
unicode={glyph.handle.unicode ?? unicode}
advance={advance}
/>
);
return <FallbackCell metrics={metrics} height={height} unicode={unicode} advance={advance} />;
}

const viewBox = glyphPreviewViewBox(metrics, advance);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/context/DebugContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function DebugProvider({ children }: DebugProviderProps) {
const editor = useEditor();

const dumpSnapshot = useCallback(() => {
const glyph = editor.glyph.peek();
const glyph = editor.previewGlyphRecordCell.peek();
if (!glyph) {
return;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/hooks/useDesignLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { AxisLocation } from "@/types/variation";

export const useDesignLocation = (): [AxisLocation, (next: AxisLocation) => void] => {
const editor = useEditor();
const location = useSignalState(editor.$designLocation);
const location = useSignalState(editor.designLocationCell);

const setLocation = useCallback((next: AxisLocation) => editor.setDesignLocation(next), [editor]);

Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,26 @@ const EMPTY_SIDEBEARINGS: GlyphSidebearings = { lsb: null, rsb: null };

export interface GlyphSidebearingsState {
readonly sidebearings: GlyphSidebearings;
readonly hasLayer: boolean;
readonly editable: boolean;
}

/**
* Current glyph sidebearings (LSB/RSB), live-updating.
*
* Subscribes to the displayed glyph instance. Interpolated instances still
* expose resolved values, but report `hasLayer: false` so inputs can display
* expose resolved values, but report `editable: false` so inputs can display
* them without mutating a missing authored glyph layer.
*
* @returns Current values and whether the displayed instance can be edited.
*/
export function useGlyphSidebearings(): GlyphSidebearingsState {
const editor = useEditor();
const instance = useSignalState(editor.glyphInstanceCell);
const instance = useSignalState(editor.previewGlyphInstanceCell);
const layer = useSignalState(editor.editingGlyphLayerCell);

useSignalTrigger(instance?.sidebearingsCell, { schedule: "frame" });
return {
sidebearings: instance?.sidebearings ?? EMPTY_SIDEBEARINGS,
hasLayer: instance?.hasLayer ?? false,
editable: layer !== null,
};
}
7 changes: 4 additions & 3 deletions apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ import { useSignalState, useSignalTrigger } from "@/lib/signals";

export interface GlyphXAdvanceState {
readonly xAdvance: number;
readonly hasLayer: boolean;
readonly editable: boolean;
}

/**
* Current glyph xAdvance, live-updating. Returns `0` when no glyph is loaded.
*/
export function useGlyphXAdvance(): GlyphXAdvanceState {
const editor = useEditor();
const instance = useSignalState(editor.glyphInstanceCell);
const instance = useSignalState(editor.previewGlyphInstanceCell);
const layer = useSignalState(editor.editingGlyphLayerCell);

useSignalTrigger(instance?.xAdvanceCell, { schedule: "frame" });

return {
xAdvance: instance?.xAdvance ?? 0,
hasLayer: instance?.hasLayer ?? false,
editable: layer !== null,
};
}
Loading
Loading