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
27 changes: 19 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,8 @@ These are allowed to jump around when energy is high, but they should not silent

- [x] Load `.designspace` files
- [x] Parse axis definitions (wght, wdth, ital, custom)
- [x] Persist continuous/discrete axes and axis value labels
- [x] Round-trip independent and cross-axis mappings
- [ ] Named instances

**Masters Editing**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ export const CreateAxisDialog = ({ open, onOpenChange }: CreateAxisDialogProps)

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
font.createAxis(
values.name,
values.tag,
Number(values.min),
Number(values.default),
Number(values.max),
values.hidden,
);
font.createAxis({
name: values.name,
tag: values.tag,
role: "external",
axisType: "continuous",
minimum: Number(values.min),
default: Number(values.default),
maximum: Number(values.max),
labels: [],
hidden: values.hidden,
});
onOpenChange(false);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ export type NormalizedLocation = Record<string, number>;

/** Asymmetric normalize — mirrors `Axis::normalize` in `shift-font`. */
export function normalizeAxis(value: number, axis: Axis): number {
const minimum = axis.minimum ?? Math.min(...(axis.values ?? [axis.default]));
const maximum = axis.maximum ?? Math.max(...(axis.values ?? [axis.default]));

if (value < axis.default) {
const range = axis.default - axis.minimum;
const range = axis.default - minimum;
return range === 0 ? 0 : (value - axis.default) / range;
}
if (value > axis.default) {
const range = axis.maximum - axis.default;
const range = maximum - axis.default;
return range === 0 ? 0 : (value - axis.default) / range;
}
return 0;
Expand Down
99 changes: 78 additions & 21 deletions apps/desktop/src/renderer/src/lib/model/Font.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
mintAxisId,
mintAxisMappingId,
mintGlyphId,
mintLayerId,
mintSourceId,
Expand Down Expand Up @@ -37,6 +38,7 @@ const SNAPSHOT: WorkspaceSnapshot = {
},
],
axes: [],
axisMappings: [],
};

describe("Font projects the workspace snapshot", () => {
Expand Down Expand Up @@ -121,13 +123,7 @@ describe("font-level intents make the font variable", () => {
{
kind: "createAxis",
createAxis: {
axisId: weightAxisId,
tag: "wght",
name: "Weight",
min: 100,
default: 400,
max: 900,
hidden: false,
axis: continuousAxis(weightAxisId),
},
},
]);
Expand Down Expand Up @@ -176,6 +172,57 @@ describe("font-level intents make the font variable", () => {
expect(stack.font.glyph(glyphId)?.layers).toEqual([{ id: layerId, sourceId }]);
});

it("projects mapping edits and evaluates them in Rust", async () => {
const stack = createWorkspaceStack();
await stack.createWorkspace();
const axisId = stack.font.createAxis({
tag: "wght",
name: "Weight",
role: "external",
axisType: "continuous",
minimum: 100,
default: 400,
maximum: 900,
labels: [],
hidden: false,
});
await stack.editCoordinator.settled();

const mappingId = mintAxisMappingId();
stack.font.setAxisMappings([
{
id: mappingId,
name: "Weight curve",
inputs: [axisId],
outputs: [axisId],
points: [
mappingPoint(axisId, 100, 100),
mappingPoint(axisId, 400, 400),
mappingPoint(axisId, 900, 800),
],
},
]);
await stack.editCoordinator.settled();

expect(stack.font.getAxisMappings().map((mapping) => mapping.id)).toEqual([mappingId]);
const mapped = await stack.font.mapLocation({
values: { [axisId]: 900 } as Record<AxisId, number>,
});
expect(mapped.values[axisId]).toBeCloseTo(800);

stack.font.deleteAxis(axisId);
await stack.editCoordinator.settled();
expect(stack.font.getAxes()).toEqual([]);
expect(stack.font.getAxisMappings()).toEqual([]);

await stack.editCoordinator.undo();
expect(stack.font.getAxes().map((axis) => axis.id)).toEqual([axisId]);
expect(stack.font.getAxisMappings().map((mapping) => mapping.id)).toEqual([mappingId]);

await stack.editCoordinator.undo();
expect(stack.font.getAxisMappings()).toEqual([]);
});

it("createGlyph authors a default layer for fresh glyphs", async () => {
const stack = createWorkspaceStack();
await stack.createWorkspace();
Expand Down Expand Up @@ -246,13 +293,7 @@ describe("font-level intents make the font variable", () => {
{
kind: "createAxis",
createAxis: {
axisId,
tag: "wght",
name: "Weight",
min: 100,
default: 400,
max: 900,
hidden: false,
axis: continuousAxis(axisId),
},
},
]);
Expand Down Expand Up @@ -304,13 +345,7 @@ describe("font-level intents make the font variable", () => {
{
kind: "createAxis",
createAxis: {
axisId,
tag: "wght",
name: "Weight",
min: 100,
default: 400,
max: 900,
hidden: false,
axis: continuousAxis(axisId),
},
},
]);
Expand Down Expand Up @@ -360,3 +395,25 @@ describe("font-level intents make the font variable", () => {
expect(glyphs.map((glyph) => glyph.id)).toEqual([b.id, a.id, b.id]);
});
});

function continuousAxis(axisId: AxisId) {
return {
id: axisId,
tag: "wght",
name: "Weight",
role: "external",
axisType: "continuous",
minimum: 100,
default: 400,
maximum: 900,
labels: [],
hidden: false,
};
}

function mappingPoint(axisId: AxisId, input: number, output: number) {
return {
input: { values: { [axisId]: input } as Record<AxisId, number> },
output: { values: { [axisId]: output } as Record<AxisId, number> },
};
}
66 changes: 56 additions & 10 deletions apps/desktop/src/renderer/src/lib/model/Font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
FontMetrics,
FontMetadata,
Axis,
AxisDefinition,
AxisMapping,
Source,
GlyphId,
GlyphRecord,
Expand Down Expand Up @@ -330,6 +332,7 @@ export class Font {
readonly #metadataCell: Signal<FontMetadata>;
readonly #sourcesCell: Signal<Source[]>;
readonly #axesCell: Signal<Axis[]>;
readonly #axisMappingsCell: Signal<AxisMapping[]>;
readonly #unicodesCell: Signal<Unicode[]>;
readonly #glyphRecordsCell: Signal<readonly GlyphRecord[]>;

Expand All @@ -352,6 +355,7 @@ export class Font {
this.#metadataCell = computed(() => workspaceCell.value?.metadata ?? {});
this.#sourcesCell = computed(() => workspaceCell.value?.sources ?? []);
this.#axesCell = computed(() => workspaceCell.value?.axes ?? []);
this.#axisMappingsCell = computed(() => workspaceCell.value?.axisMappings ?? []);
this.#directoryCell = computed(() =>
GlyphDirectory.fromRecords(workspaceCell.value?.glyphs ?? []),
);
Expand Down Expand Up @@ -398,6 +402,11 @@ export class Font {
return this.#axesCell;
}

/** Reactive font-owned independent and cross-axis mappings. */
get axisMappingsCell(): Signal<AxisMapping[]> {
return this.#axisMappingsCell;
}

/** Reactive committed variation sources for sidebar controls. */
get sourcesCell(): Signal<Source[]> {
return this.#sourcesCell;
Expand Down Expand Up @@ -1211,24 +1220,46 @@ export class Font {
return sourceId;
}

/** @knipclassignore — used by VariationPanel component */
createAxis(
name: string,
tag: string,
min: number,
def: number,
max: number,
hidden: boolean = false,
): AxisId {
/**
* Creates one variation axis with renderer-minted stable identity.
*
* @param axis - Complete axis definition apart from the id assigned here.
* @returns The id submitted to the workspace.
*/
createAxis(axis: AxisDefinition): AxisId {
const axisId = mintAxisId();
this.editCoordinator.push({
kind: "createAxis",
createAxis: { axisId, name, tag, min, default: def, max, hidden },
createAxis: { axis: { id: axisId, ...axis } },
});

return axisId;
}

/**
* Replaces an existing axis definition while preserving its stable id.
*
* @param axis - Complete replacement axis returned from the current font model.
*/
updateAxis(axis: Axis): void {
this.editCoordinator.push({
kind: "updateAxis",
updateAxis: { axis },
});
}

/**
* Replaces the font-owned mapping collection as one undoable edit.
*
* @param mappings - Complete ordered mapping collection; Rust validates all axis references.
*/
setAxisMappings(mappings: readonly AxisMapping[]): void {
this.editCoordinator.push({
kind: "setAxisMappings",
setAxisMappings: { mappings: [...mappings] },
});
}

/** @knipclassignore — used by VariationPanel component */
deleteAxis(axisId: AxisId): void {
this.editCoordinator.push({
Expand All @@ -1249,6 +1280,21 @@ export class Font {
return this.#axesCell.peek();
}

/** Returns the current font-owned mapping collection. */
getAxisMappings(): AxisMapping[] {
return this.#axisMappingsCell.peek();
}

/**
* Evaluates independent mappings followed by cross-axis mappings in Rust.
*
* @param location - External location keyed by stable axis id; omitted axes use defaults.
* @returns The mapped location after pending workspace edits have settled.
*/
mapLocation(location: Location): Promise<Location> {
return this.editCoordinator.mapLocation(location);
}

/** @knipclassignore — used by VariationPanel component */
get sources(): Source[] {
return this.#sourcesCell.peek();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/lib/model/FontStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ function snapshot(documentId: string, layerId: LayerId): WorkspaceSnapshot {
},
],
axes: [],
axisMappings: [],
};
}

Expand Down
Loading
Loading