Skip to content

Commit 7f1678e

Browse files
authored
Merge pull request #153 from shift-editor/agent/glyph-projection-views
Use reusable glyph projections for renderer views
2 parents e4a2ab8 + a69f0fa commit 7f1678e

47 files changed

Lines changed: 1909 additions & 1124 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
"@shift/types": "workspace:*",
6161
"@shift/ui": "workspace:*",
6262
"@shift/validation": "workspace:*",
63-
"@tanstack/react-query": "^5.101.2",
6463
"@tanstack/react-virtual": "^3.13.18",
6564
"@types/react": "^19.1.8",
6665
"@types/react-dom": "^19.1.6",

apps/desktop/src/renderer/src/app/App.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
11
import "./App.css";
2-
import { QueryClientProvider } from "@tanstack/react-query";
32
import { HashRouter } from "react-router-dom";
43

54
import { ThemeProvider } from "@/context/ThemeContext";
65
import { FocusZoneProvider } from "@/context/FocusZoneContext";
76
import { ZoomToast } from "@/components/chrome/ZoomToast";
8-
import { rendererQueryClient } from "@/lib/query/queryClient";
97

108
import { Screens } from "./Screens";
119

1210
export const App = () => {
1311
return (
14-
<QueryClientProvider client={rendererQueryClient}>
15-
<ThemeProvider defaultTheme="light">
16-
<ZoomToast>
17-
<FocusZoneProvider defaultZone="canvas">
18-
<HashRouter>
19-
<Screens />
20-
</HashRouter>
21-
</FocusZoneProvider>
22-
</ZoomToast>
23-
</ThemeProvider>
24-
</QueryClientProvider>
12+
<ThemeProvider defaultTheme="light">
13+
<ZoomToast>
14+
<FocusZoneProvider defaultZone="canvas">
15+
<HashRouter>
16+
<Screens />
17+
</HashRouter>
18+
</FocusZoneProvider>
19+
</ZoomToast>
20+
</ThemeProvider>
2521
);
2622
};
2723

apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx

Lines changed: 14 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,22 @@
4343

4444
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4545
import { useNavigate } from "react-router-dom";
46-
import { useQueries } from "@tanstack/react-query";
4746
import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
4847
import { CELL_HEIGHT, GlyphPreview } from "@/components/home/GlyphPreview";
49-
import { useEditor, useWorkspace } from "@/workspace/WorkspaceContext";
48+
import { useEditor } from "@/workspace/WorkspaceContext";
5049
import { getGlyphInfo } from "@/workspace/glyphInfo";
5150
import { type GlyphCatalogItem, useGlyphCatalog } from "@/context/GlyphCatalogContext";
52-
import { useSignalState } from "@/lib/signals";
51+
import { useGlyphViews } from "@/hooks/useGlyphViews";
5352
import { Button, Input } from "@shift/ui";
54-
import type { GlyphId, GlyphName, GlyphPreview as GlyphPreviewValue } from "@shift/types";
53+
import type { GlyphId, GlyphName } from "@shift/types";
5554

5655
const ROW_HEIGHT = CELL_HEIGHT + 40 + 8;
5756
const NOMINAL_CELL_WIDTH = 100;
5857
const GAP = 8;
5958
const SCROLL_PADDING = 4;
6059
const ROW_PADDING_X = 4;
6160
const OVERSCAN = 5;
62-
const GLYPH_QUERY_CHUNK_SIZE = 32;
63-
const GLYPH_QUERY_OVERSCAN_CHUNKS = 1;
64-
const GLYPH_QUERY_GC_TIME = 30_000;
61+
const PROJECTION_OVERSCAN = 32;
6562

6663
function computeLayout(width: number) {
6764
const availableWidth = width - SCROLL_PADDING - ROW_PADDING_X;
@@ -91,13 +88,9 @@ function visibleGlyphRowsForRows(
9188

9289
export const GlyphGrid = memo(function GlyphGrid() {
9390
const navigate = useNavigate();
94-
const workspace = useWorkspace();
95-
const editor = workspace.editor;
91+
const editor = useEditor();
9692
const font = editor.font;
9793
const metrics = font.metrics;
98-
const designLocation = useSignalState(editor.designLocationCell, { schedule: "frame" });
99-
const documentState = useSignalState(workspace.documentStateCell);
100-
const documentId = documentState?.documentId ?? null;
10194
const { filteredGlyphs: catalogGlyphs } = useGlyphCatalog();
10295

10396
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -148,59 +141,17 @@ export const GlyphGrid = memo(function GlyphGrid() {
148141
const visibleEndIndex = lastVisibleRow
149142
? Math.min(catalogGlyphs.length, (lastVisibleRow.index + 1) * columns)
150143
: 0;
151-
const previewGlyphChunks = useMemo((): readonly (readonly GlyphId[])[] => {
144+
const previewGlyphIds = useMemo((): readonly GlyphId[] => {
152145
if (visibleEndIndex <= visibleStartIndex) return [];
153146

154-
const queryStartIndex = Math.max(
155-
0,
156-
visibleStartIndex - GLYPH_QUERY_OVERSCAN_CHUNKS * GLYPH_QUERY_CHUNK_SIZE,
157-
);
158-
const queryEndIndex = Math.min(
159-
catalogGlyphs.length,
160-
visibleEndIndex + GLYPH_QUERY_OVERSCAN_CHUNKS * GLYPH_QUERY_CHUNK_SIZE,
161-
);
162-
const firstChunkStart =
163-
Math.floor(queryStartIndex / GLYPH_QUERY_CHUNK_SIZE) * GLYPH_QUERY_CHUNK_SIZE;
164-
const chunks: GlyphId[][] = [];
165-
166-
for (
167-
let chunkStart = firstChunkStart;
168-
chunkStart < queryEndIndex;
169-
chunkStart += GLYPH_QUERY_CHUNK_SIZE
170-
) {
171-
chunks.push(
172-
catalogGlyphs
173-
.slice(chunkStart, chunkStart + GLYPH_QUERY_CHUNK_SIZE)
174-
.map((glyph) => glyph.id),
175-
);
176-
}
177-
178-
return chunks;
147+
const startIndex = Math.max(0, visibleStartIndex - PROJECTION_OVERSCAN);
148+
const endIndex = Math.min(catalogGlyphs.length, visibleEndIndex + PROJECTION_OVERSCAN);
149+
return catalogGlyphs.slice(startIndex, endIndex).map((glyph) => glyph.id);
179150
}, [catalogGlyphs, visibleEndIndex, visibleStartIndex]);
180-
const locationKey = useMemo(
181-
() => [...designLocation].sort(([left], [right]) => left.localeCompare(right)),
182-
[designLocation],
183-
);
184-
const glyphChunkQueries = useQueries({
185-
queries: previewGlyphChunks.map((glyphIds) => ({
186-
queryKey: ["glyph-previews", documentId, locationKey, glyphIds] as const,
187-
queryFn: async (): Promise<readonly GlyphPreviewValue[]> => {
188-
try {
189-
return await font.glyphPreviews(glyphIds, designLocation);
190-
} catch (error) {
191-
console.error("failed to resolve glyph preview chunk", error);
192-
throw error;
193-
}
194-
},
195-
enabled: documentId !== null,
196-
staleTime: Infinity,
197-
gcTime: GLYPH_QUERY_GC_TIME,
198-
})),
199-
});
200-
const previewsByGlyphId = new Map(
201-
glyphChunkQueries
202-
.flatMap((query) => query.data ?? [])
203-
.map((preview) => [preview.glyphId, preview] as const),
151+
const glyphViews = useGlyphViews(font, previewGlyphIds, editor.designLocationCell);
152+
const viewsByGlyphId = useMemo(
153+
() => new Map(previewGlyphIds.map((glyphId, index) => [glyphId, glyphViews[index]] as const)),
154+
[glyphViews, previewGlyphIds],
204155
);
205156

206157
const handleCellClick = useCallback(
@@ -263,8 +214,7 @@ export const GlyphGrid = memo(function GlyphGrid() {
263214
onClick={() => handleCellClick(glyph)}
264215
>
265216
<GlyphPreview
266-
preview={previewsByGlyphId.get(glyph.id) ?? null}
267-
unicode={glyph.unicode}
217+
view={viewsByGlyphId.get(glyph.id) ?? null}
268218
metrics={metrics}
269219
height={CELL_HEIGHT}
270220
/>

apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { FontMetrics, GlyphPreview as GlyphPreviewValue } from "@shift/types";
1+
import type { FontMetrics } from "@shift/types";
2+
import { useSignalState } from "@/lib/signals";
3+
import type { GlyphView } from "@/lib/model/Glyph";
24

35
export const CELL_HEIGHT = 75;
46

@@ -40,36 +42,32 @@ export function computeCellWidth(
4042
}
4143

4244
interface GlyphPreviewProps {
43-
preview: GlyphPreviewValue | null;
44-
unicode: number | null;
45+
view: GlyphView | null;
4546
metrics: FontMetrics;
4647
height?: number;
4748
}
4849

49-
export function GlyphPreview({
50-
preview,
51-
unicode,
52-
metrics,
53-
height = CELL_HEIGHT,
54-
}: GlyphPreviewProps) {
55-
if (!preview) {
56-
return <FallbackCell metrics={metrics} height={height} unicode={unicode} advance={null} />;
50+
export function GlyphPreview({ view, metrics, height = CELL_HEIGHT }: GlyphPreviewProps) {
51+
if (!view) {
52+
// Showing the system font here makes fast scrolling flash the wrong glyph
53+
// before the authored projection arrives.
54+
return <div style={{ width: height, height }} />;
5755
}
5856

59-
return <GlyphCell metrics={metrics} height={height} preview={preview} />;
57+
return <GlyphCell metrics={metrics} height={height} view={view} />;
6058
}
6159

6260
function GlyphCell({
6361
metrics,
6462
height,
65-
preview,
63+
view,
6664
}: {
6765
metrics: FontMetrics;
6866
height: number;
69-
preview: GlyphPreviewValue;
67+
view: GlyphView;
7068
}) {
71-
const svgPath = preview.svgPath;
72-
const advance = preview.xAdvance;
69+
const svgPath = useSignalState(view.render.outline.svgPathCell, { schedule: "frame" });
70+
const advance = useSignalState(view.xAdvanceCell, { schedule: "frame" });
7371

7472
const cellWidth = computeCellWidth(metrics, advance, height);
7573
const containerStyle = { width: cellWidth, height };
@@ -96,29 +94,3 @@ function GlyphCell({
9694
</div>
9795
);
9896
}
99-
100-
function FallbackCell({
101-
metrics,
102-
height,
103-
unicode,
104-
advance,
105-
}: {
106-
metrics: FontMetrics;
107-
height: number;
108-
unicode: number | null;
109-
advance: number | null;
110-
}) {
111-
const cellWidth = computeCellWidth(metrics, advance, height);
112-
const label = unicode === null ? "" : String.fromCodePoint(unicode);
113-
114-
return (
115-
<div
116-
style={{ width: cellWidth, height }}
117-
className="flex items-center justify-center text-secondary"
118-
>
119-
<span className="text-2xl" style={{ fontSize: height * 0.5 }}>
120-
{label}
121-
</span>
122-
</div>
123-
);
124-
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useEffect, useMemo } from "react";
2+
import type { GlyphId } from "@shift/types";
3+
import type { Signal } from "@/lib/signals";
4+
import { useSignalState } from "@/lib/signals";
5+
import type { Font } from "@/lib/model/Font";
6+
import type { GlyphView } from "@/lib/model/Glyph";
7+
import type { AxisLocation } from "@/types/variation";
8+
9+
/**
10+
* Returns location-bound glyph views and requests any missing lightweight backing.
11+
*
12+
* @remarks
13+
* Projection reads are keyed only by glyph identity. The returned views follow
14+
* `location` through Shift's signal graph, so scrubbing evaluates the currently
15+
* observed views without issuing bridge reads or retaining old locations.
16+
*
17+
* @param font - Font that owns the glyphs and their projection backing.
18+
* @param glyphIds - Stable ordered glyph identities needed by the caller.
19+
* @param location - Shared reactive designspace location for every returned view.
20+
* @returns Views aligned with `glyphIds`; entries remain `null` until backing arrives.
21+
*/
22+
export function useGlyphViews(
23+
font: Font,
24+
glyphIds: readonly GlyphId[],
25+
location: Signal<AxisLocation>,
26+
): readonly (GlyphView | null)[] {
27+
const viewsCell = useMemo(
28+
() => font.glyphViewsCell(glyphIds, location),
29+
[font, glyphIds, location],
30+
);
31+
const views = useSignalState(viewsCell);
32+
33+
useEffect(() => {
34+
async function requestViews(): Promise<void> {
35+
try {
36+
await font.requestGlyphViews(glyphIds);
37+
} catch (error) {
38+
console.error("failed to read glyph projections", error);
39+
}
40+
}
41+
42+
void requestViews();
43+
}, [font, glyphIds, views]);
44+
45+
return views;
46+
}

apps/desktop/src/renderer/src/lib/editor/rendering/overlays/DebugOverlays.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { Canvas } from "../Canvas";
2-
import type { GlyphInstanceGeometry } from "@/lib/model/Glyph";
2+
import type { GlyphViewGeometry } from "@/lib/model/Glyph";
33
import type { SegmentId } from "@/types/indicator";
44

55
export class DebugOverlays {
66
draw(
77
canvas: Canvas,
8-
geometry: GlyphInstanceGeometry,
8+
geometry: GlyphViewGeometry,
99
overlays: {
1010
segmentBounds: boolean;
1111
tightBounds: boolean;
@@ -31,7 +31,7 @@ export class DebugOverlays {
3131
}
3232
}
3333

34-
#drawSegmentBounds(canvas: Canvas, geometry: GlyphInstanceGeometry, color: string): void {
34+
#drawSegmentBounds(canvas: Canvas, geometry: GlyphViewGeometry, color: string): void {
3535
for (const contour of geometry.contours) {
3636
for (const segment of contour.segments()) {
3737
const b = segment.bounds;
@@ -42,7 +42,7 @@ export class DebugOverlays {
4242

4343
#drawTightBounds(
4444
canvas: Canvas,
45-
geometry: GlyphInstanceGeometry,
45+
geometry: GlyphViewGeometry,
4646
hoveredSegmentId: SegmentId | null,
4747
color: string,
4848
): void {
@@ -59,7 +59,7 @@ export class DebugOverlays {
5959

6060
#drawHitRadii(
6161
canvas: Canvas,
62-
geometry: GlyphInstanceGeometry,
62+
geometry: GlyphViewGeometry,
6363
hitRadiusUpm: number,
6464
color: string,
6565
): void {
@@ -69,7 +69,7 @@ export class DebugOverlays {
6969
}
7070
}
7171

72-
#drawGlyphBbox(canvas: Canvas, geometry: GlyphInstanceGeometry, color: string): void {
72+
#drawGlyphBbox(canvas: Canvas, geometry: GlyphViewGeometry, color: string): void {
7373
const b = geometry.bounds;
7474
if (!b) return;
7575
canvas.strokeRect(b.min.x, b.min.y, b.max.x - b.min.x, b.max.y - b.min.y, color, 1);

apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GlyphInstance } from "@/lib/model/Glyph";
1+
import type { GlyphView } from "@/lib/model/Glyph";
22
import type { Hover } from "@/lib/editor/Hover";
33
import type { Selection } from "@/lib/editor/Selection";
44
import type { GlyphNode } from "@/types/node";
@@ -21,11 +21,11 @@ export class Handles {
2121
draw(
2222
ctx: RenderContext,
2323
node: GlyphNode,
24-
instance: GlyphInstance,
24+
view: GlyphView,
2525
selection: Selection,
2626
hover: Hover,
2727
): void {
28-
const list = this.#items.fromContours(instance.render.contours, {
28+
const list = this.#items.fromContours(view.render.contours, {
2929
selection,
3030
hover,
3131
});

apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Segments.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { Canvas } from "../Canvas";
22
import type { Segment, SegmentId } from "@shift/glyph-state";
3-
import type { GlyphInstanceGeometry } from "@/lib/model/Glyph";
3+
import type { GlyphViewGeometry } from "@/lib/model/Glyph";
44

55
export class Segments {
66
readonly #selected: Segment[] = [];
77

88
draw(
99
canvas: Canvas,
10-
geometry: GlyphInstanceGeometry,
10+
geometry: GlyphViewGeometry,
1111
selectedSegmentIds: readonly SegmentId[],
1212
hoveredSegmentId: SegmentId | null,
1313
): void {

0 commit comments

Comments
 (0)