diff --git a/Cargo.lock b/Cargo.lock index 392877fd3..e474509ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,6 +795,15 @@ dependencies = [ "serde", ] +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontbe" version = "0.5.0" @@ -1100,6 +1109,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.9.1", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.15.4" @@ -1778,9 +1800,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2067,6 +2089,17 @@ dependencies = [ "serde", ] +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types 0.12.5", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.5.13" @@ -2314,6 +2347,7 @@ dependencies = [ "shift-backends", "shift-font", "shift-slug", + "shift-specimen", "shift-wire", "shift-workspace", "thiserror", @@ -2366,6 +2400,15 @@ dependencies = [ "wgpu", ] +[[package]] +name = "shift-specimen" +version = "0.1.0" +dependencies = [ + "harfrust", + "skrifa", + "unicode-script", +] + [[package]] name = "shift-store" version = "0.1.0" @@ -2715,6 +2758,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" diff --git a/Cargo.toml b/Cargo.toml index 40b5a0059..b4b6657ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ shift-backends = { path = "crates/shift-backends" } shift-bridge = { path = "crates/shift-bridge" } shift-font = { path = "crates/shift-font" } shift-slug = { path = "crates/shift-slug" } +shift-specimen = { path = "crates/shift-specimen" } shift-store = { path = "crates/shift-store" } shift-workspace = { path = "crates/shift-workspace" } diff --git a/apps/desktop/e2e/__screenshots__/landing.spec.ts/landing-default.png b/apps/desktop/e2e/__screenshots__/landing.spec.ts/landing-default.png index 137c9d996..bf13d92f6 100644 Binary files a/apps/desktop/e2e/__screenshots__/landing.spec.ts/landing-default.png and b/apps/desktop/e2e/__screenshots__/landing.spec.ts/landing-default.png differ diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-pen.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-pen.png index 56d33c2ff..7a920e11e 100644 Binary files a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-pen.png and b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-pen.png differ diff --git a/apps/desktop/e2e/application-menu.spec.ts b/apps/desktop/e2e/application-menu.spec.ts index 3f8c76350..ac45e15e9 100644 --- a/apps/desktop/e2e/application-menu.spec.ts +++ b/apps/desktop/e2e/application-menu.spec.ts @@ -21,7 +21,7 @@ const convertiblePreviewTest = launcherTest.extend({ async function openSelectedPreview(page: Page, electronApp: ElectronApplication): Promise { const workspaceWindow = electronApp.waitForEvent("window"); - await page.getByRole("button", { name: /Load font/ }).click(); + await page.getByRole("button", { name: "Open Font…", exact: true }).click(); const workspacePage = await workspaceWindow; await workspacePage.waitForURL(/#\/home$/); diff --git a/apps/desktop/e2e/document-lifecycle.spec.ts b/apps/desktop/e2e/document-lifecycle.spec.ts index 98a3e5351..f52271659 100644 --- a/apps/desktop/e2e/document-lifecycle.spec.ts +++ b/apps/desktop/e2e/document-lifecycle.spec.ts @@ -103,7 +103,7 @@ const failedPreviewSaveTest = convertiblePreviewTest.extend({ async function openSelectedPreview(page: Page, electronApp: ElectronApplication): Promise { const workspaceWindow = electronApp.waitForEvent("window"); - await page.getByRole("button", { name: /Load font/ }).click(); + await page.getByRole("button", { name: "Open Font…", exact: true }).click(); const workspacePage = await workspaceWindow; await workspacePage.waitForURL(/#\/home$/); @@ -216,7 +216,7 @@ test.describe("unsupported file activation", () => { test.use({ electronArgs: [path.join(path.dirname(FONT_PATH), "ignored.txt")] }); test("keeps the launcher for unsupported extensions", async ({ electronApp, page }) => { - await expect(page.getByRole("button", { name: /Load font/ })).toBeVisible(); + await expect(page.getByRole("button", { name: "Open Font…", exact: true })).toBeVisible(); await expect(page).toHaveURL(/#\/launcher$/); expect(electronApp.windows()).toHaveLength(1); }); @@ -436,7 +436,7 @@ convertiblePreviewTest( const launcherPage = await relaunchedApp.firstWindow(); await launcherPage.waitForURL(/#\/launcher$/); const reopenedWindow = relaunchedApp.waitForEvent("window"); - await launcherPage.getByRole("button", { name: /Load font/ }).click(); + await launcherPage.getByRole("button", { name: "Open Font…", exact: true }).click(); const reopenedPage = await reopenedWindow; await waitForWorkspaceReady(reopenedPage); @@ -576,7 +576,7 @@ test.describe("document lifecycle through the application shell", () => { await launcherPage.waitForURL(/#\/launcher$/); const reopenedWindow = relaunchedApp.waitForEvent("window"); - await launcherPage.getByRole("button", { name: /Load font/ }).click(); + await launcherPage.getByRole("button", { name: "Open Font…", exact: true }).click(); const reopenedPage = await reopenedWindow; await reopenedPage.waitForURL(/#\/home$/); await expect(reopenedPage.getByLabel("Glyph catalog", { exact: true })).toBeVisible(); @@ -702,7 +702,7 @@ discardTest( const launcherPage = await relaunchedApp.firstWindow(); await launcherPage.waitForURL(/#\/launcher$/); const reopenedWindow = relaunchedApp.waitForEvent("window"); - await launcherPage.getByRole("button", { name: /Load font/ }).click(); + await launcherPage.getByRole("button", { name: "Open Font…", exact: true }).click(); const reopenedPage = await reopenedWindow; await waitForWorkspaceReady(reopenedPage); diff --git a/apps/desktop/e2e/fixtures/documentLifecycle.ts b/apps/desktop/e2e/fixtures/documentLifecycle.ts index afc489483..f4a752b84 100644 --- a/apps/desktop/e2e/fixtures/documentLifecycle.ts +++ b/apps/desktop/e2e/fixtures/documentLifecycle.ts @@ -9,7 +9,7 @@ import { waitForWorkspaceReady } from "./electronApp"; export async function createNewFont(page: Page, electronApp: ElectronApplication): Promise { const workspaceWindow = electronApp.waitForEvent("window"); - await page.getByRole("button", { name: "New font", exact: true }).click(); + await page.getByRole("button", { name: "New Font", exact: true }).click(); const workspacePage = await workspaceWindow; await waitForWorkspaceReady(workspacePage); diff --git a/apps/desktop/e2e/handle-styling.spec.ts b/apps/desktop/e2e/handle-styling.spec.ts index cab2581ad..85ef0f81c 100644 --- a/apps/desktop/e2e/handle-styling.spec.ts +++ b/apps/desktop/e2e/handle-styling.spec.ts @@ -119,7 +119,7 @@ previewTest( "TTF source handles keep source styling without becoming hoverable", async ({ page, electronApp }) => { const workspaceWindow = electronApp.waitForEvent("window"); - await page.getByRole("button", { name: /Load font/ }).click(); + await page.getByRole("button", { name: "Open Font…", exact: true }).click(); const workspacePage = await workspaceWindow; await workspacePage.waitForURL(/#\/home$/); await clickFirstCatalogGlyph(workspacePage); diff --git a/apps/desktop/e2e/landing.spec.ts b/apps/desktop/e2e/landing.spec.ts index 21d534846..7256c68e1 100644 --- a/apps/desktop/e2e/landing.spec.ts +++ b/apps/desktop/e2e/landing.spec.ts @@ -12,7 +12,7 @@ test.describe("Landing view", () => { test("creates an editable font through New font", async ({ electronApp, page }) => { const workspaceWindow = electronApp.waitForEvent("window"); - await page.getByRole("button", { name: "New font", exact: true }).click(); + await page.getByRole("button", { name: "New Font", exact: true }).click(); const workspacePage = await workspaceWindow; await workspacePage.waitForURL(/#\/home$/); diff --git a/apps/desktop/e2e/platform-integration.spec.ts b/apps/desktop/e2e/platform-integration.spec.ts index ce29254da..4fa9deff3 100644 --- a/apps/desktop/e2e/platform-integration.spec.ts +++ b/apps/desktop/e2e/platform-integration.spec.ts @@ -94,7 +94,7 @@ async function reopenAndVerify( const launcherPage = await relaunchedApp.firstWindow(); await launcherPage.waitForURL(/#\/launcher$/); const workspaceWindow = relaunchedApp.waitForEvent("window"); - await launcherPage.getByRole("button", { name: /Load font/ }).click(); + await launcherPage.getByRole("button", { name: "Open Font…", exact: true }).click(); const workspacePage = await workspaceWindow; await waitForWorkspaceReady(workspacePage); const editor = new EditorDriver(workspacePage); diff --git a/apps/desktop/e2e/preview-notice.spec.ts b/apps/desktop/e2e/preview-notice.spec.ts index a90ae5830..1ed8df66c 100644 --- a/apps/desktop/e2e/preview-notice.spec.ts +++ b/apps/desktop/e2e/preview-notice.spec.ts @@ -7,7 +7,7 @@ const test = documentTest.extend({ test("read-only preview notice matches snapshot", async ({ electronApp, page }) => { const workspaceWindow = electronApp.waitForEvent("window"); - await page.getByRole("button", { name: /Load font/ }).click(); + await page.getByRole("button", { name: "Open Font…", exact: true }).click(); const workspacePage = await workspaceWindow; await expect(workspacePage.getByLabel("Glyph catalog", { exact: true })).toBeVisible(); await expect.poll(() => workspacePage.evaluate(() => window.shiftSession?.mode)).toBe("preview"); diff --git a/apps/desktop/e2e/recent-files.spec.ts b/apps/desktop/e2e/recent-files.spec.ts new file mode 100644 index 000000000..c9a6197ac --- /dev/null +++ b/apps/desktop/e2e/recent-files.spec.ts @@ -0,0 +1,92 @@ +import type { ElectronApplication, Page } from "@playwright/test"; +import { documentTest, expect, FONT_PATH } from "./fixtures/electronApp"; + +const test = documentTest.extend({ + openFontPath: FONT_PATH, +}); + +const FONT_NAME = "MutatorSans.ttf"; + +async function openFontFromLauncher(page: Page, electronApp: ElectronApplication): Promise { + const workspaceWindow = electronApp.waitForEvent("window"); + await page.getByRole("button", { name: "Open Font…", exact: true }).click(); + + const workspacePage = await workspaceWindow; + await workspacePage.waitForURL(/#\/home$/); + await expect(workspacePage.getByLabel("Glyph catalog", { exact: true })).toBeVisible(); + return workspacePage; +} + +function recentFile(launcher: Page) { + return launcher + .getByRole("list", { name: "Recent files" }) + .getByRole("listitem") + .filter({ hasText: FONT_NAME }); +} + +test("a font opened once is listed in Recent files after relaunch and reopens from its card", async ({ + electronApp, + page, + relaunch, +}) => { + await openFontFromLauncher(page, electronApp); + await electronApp.close(); + + const restarted = await relaunch(); + const launcher = await restarted.firstWindow(); + await launcher.waitForURL(/#\/launcher$/); + await expect(recentFile(launcher)).toHaveCount(1); + // MutatorSans has capitals only, so its thumbnail specimen is "AG". + await expect(recentFile(launcher)).toHaveAttribute("data-specimen-text", "AG"); + + const workspaceWindow = restarted.waitForEvent("window"); + await recentFile(launcher) + .getByRole("button", { name: new RegExp(FONT_NAME) }) + .click(); + const workspacePage = await workspaceWindow; + await workspacePage.waitForURL(/#\/home$/); + await expect.poll(() => workspacePage.evaluate(() => window.shiftSession?.mode)).toBe("preview"); +}); + +test("removing a recent file can be undone from the toast", async ({ + electronApp, + page, + relaunch, +}) => { + await openFontFromLauncher(page, electronApp); + await electronApp.close(); + + const restarted = await relaunch(); + const launcher = await restarted.firstWindow(); + await expect(recentFile(launcher)).toHaveCount(1); + + await recentFile(launcher).getByRole("button", { name: "More actions" }).click(); + await launcher.getByRole("menuitem", { name: "Remove from Recents" }).click(); + await expect(recentFile(launcher)).toHaveCount(0); + + await launcher.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(recentFile(launcher)).toHaveCount(1); +}); + +test("File › Open Recent lists opened fonts and clears them", async ({ electronApp, page }) => { + await openFontFromLauncher(page, electronApp); + + const openRecentLabels = () => + electronApp.evaluate(({ Menu }) => { + const file = Menu.getApplicationMenu()?.items.find((item) => item.label === "File"); + const openRecent = file?.submenu?.items.find((item) => item.label === "Open Recent"); + return openRecent?.submenu?.items.map((item) => item.label) ?? null; + }); + + await expect.poll(openRecentLabels).toEqual([FONT_NAME, "", "Clear Menu"]); + + await electronApp.evaluate(({ Menu }) => { + const file = Menu.getApplicationMenu()?.items.find((item) => item.label === "File"); + const openRecent = file?.submenu?.items.find((item) => item.label === "Open Recent"); + const clear = openRecent?.submenu?.items.find((item) => item.label === "Clear Menu"); + if (!clear) throw new Error("Missing Clear Menu item"); + clear.click(); + }); + + await expect.poll(openRecentLabels).toEqual(["Clear Menu"]); +}); diff --git a/apps/desktop/e2e/window-behavior.spec.ts b/apps/desktop/e2e/window-behavior.spec.ts index 30c3166cd..ee47aeba4 100644 --- a/apps/desktop/e2e/window-behavior.spec.ts +++ b/apps/desktop/e2e/window-behavior.spec.ts @@ -4,14 +4,17 @@ import { runCommand } from "./fixtures/documentLifecycle"; // Window behavior must be observed before visual normalization changes native geometry. test.use({ windowSizing: "native" }); -test("opens the launcher at its compact native size", async ({ electronApp, page }) => { - await expect(page.getByRole("button", { name: "New font", exact: true })).toBeVisible(); +test("opens the launcher at 960×720, within 90% of the screen", async ({ electronApp, page }) => { + await expect(page.getByRole("button", { name: "New Font", exact: true })).toBeVisible(); const browserWindow = await electronApp.browserWindow(page); + const workArea = await electronApp.evaluate(({ screen }) => screen.getPrimaryDisplay().workArea); + const expected = [ + Math.min(960, Math.round(workArea.width * 0.9)), + Math.min(720, Math.round(workArea.height * 0.9)), + ]; try { - await expect - .poll(() => browserWindow.evaluate((window) => window.getSize())) - .toEqual([800, 600]); + await expect.poll(() => browserWindow.evaluate((window) => window.getSize())).toEqual(expected); } finally { await browserWindow.dispose(); } diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index a94bb3d0a..e87db0585 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -18,6 +18,7 @@ export const PLATFORM_SPECS = [ "document-lifecycle.spec.ts", "document-recovery.spec.ts", "platform-integration.spec.ts", + "recent-files.spec.ts", "variable-font-recovery.spec.ts", "window-behavior.spec.ts", "window-isolation.spec.ts", diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index 4a90ee7fd..f717e49b3 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -5,6 +5,7 @@ import { ipcMain, MessageChannelMain, screen, + shell, type Rectangle, type WebContents, } from "electron"; @@ -29,11 +30,18 @@ import { shiftProductName } from "../release"; import { AppUpdater } from "../update/AppUpdater"; import { isConvertiblePreviewPath } from "../../shared/workspace/previewConversion"; import { OPEN_FONT_EXTENSIONS } from "../../shared/openFontExtensions"; +import { RecentDocuments } from "../recents/RecentDocuments"; +import type { RecentDocumentVisit } from "../../shared/recents"; const SLUG_ATLAS_PROFILING_ENABLED = process.env.SHIFT_PROFILE_SLUG_ATLAS !== undefined && process.env.SHIFT_PROFILE_SLUG_ATLAS !== "0"; -const LAUNCHER_MIN_WIDTH = 800; +const LAUNCHER_MIN_WIDTH = 880; +const LAUNCHER_WIDTH = 960; +const LAUNCHER_HEIGHT = 720; +/** Largest share of the screen the launcher takes on displays smaller than its size. */ +const LAUNCHER_MAX_SCREEN_SHARE = 0.9; +const LAUNCHER_SHOW_FALLBACK_MS = 2000; /** * Owns Electron app startup and the first main-process service graph. @@ -55,6 +63,9 @@ export class App { #windows = new WindowManager(); #workspaces: WorkspaceManager; #documentsRoot: string | null = null; + #recents: RecentDocuments | null = null; + /** Launchers a font open is replacing; they stop receiving recents so no half-ready card flashes. */ + #replacedLaunchers = new WeakSet(); #pendingOpenPaths: string[] = []; #previewConversions = new Map>(); #documentCrashDecisions = new Map>(); @@ -86,6 +97,11 @@ export class App { return this.#commands.isEnabled(id, this.#commandContext(window)); }, + () => ({ + documents: this.#recents?.list() ?? [], + open: (sourcePath) => this.#openRecentFromMenu(sourcePath), + clear: () => this.#recents?.clear(), + }), ); /** @@ -113,6 +129,11 @@ export class App { applicationName: () => this.applicationName, nativeDialogs: this.#nativeDialogs, onSessionCrashed: (session) => this.#handleDocumentCrash(session, null), + onDocumentVisited: (visit, session) => { + this.#recents?.record(visit); + if (this.#recents?.needsSpecimen(visit)) void this.#buildSpecimen(visit, session); + }, + onDocumentSaved: (visit, session) => void this.#buildSpecimen(visit, session), }); this.#lifecycle = new AppLifecycle({ documentForWindow: (window) => { @@ -188,6 +209,10 @@ export class App { this.#log.info("running when ready callback"); this.#documentsRoot = path.join(app.getPath("userData"), "working-documents"); + this.#recents = new RecentDocuments( + path.join(app.getPath("userData"), "recent-documents.json"), + ); + this.#recents.onChanged(() => this.#publishRecents()); const restoredSessions = await this.#workspaces.restoreRecoveries(); for (const session of restoredSessions) { @@ -276,12 +301,27 @@ export class App { return window; } + /** + * Creates the launcher hidden and shows it once its recent files have rendered. + * + * @remarks + * The renderer signals `window.ready`; the timer shows the window anyway if + * that signal never arrives, so a renderer failure cannot leave it invisible. + */ #openLauncher(): Window { - const window = this.#createWindow(true, undefined, false, LAUNCHER_MIN_WIDTH); + const window = this.#createWindow(false, launcherBounds(), false, LAUNCHER_MIN_WIDTH); this.#loadLauncher(window); + setTimeout(() => this.#presentIfHidden(window), LAUNCHER_SHOW_FALLBACK_MS); return window; } + #presentIfHidden(window: Window): void { + const browserWindow = window.window; + if (browserWindow.isDestroyed() || browserWindow.isVisible()) return; + + window.present(); + } + #loadLauncher(window: Window): void { this.#loadRenderer(window, "/launcher"); } @@ -416,6 +456,30 @@ export class App { ipc.handle(ipcMain, "update.later", () => { this.#updater.later(); }); + ipc.handle(ipcMain, "recents.list", () => { + return this.#recents?.list() ?? []; + }); + ipc.handle(ipcMain, "recents.open", async (event, sourcePath) => { + const window = this.#requireWindowForWebContents(event.sender); + await this.#openPathFromWindow(window, sourcePath); + }); + ipc.handle(ipcMain, "recents.remove", (_event, sourcePath) => { + return this.#recents?.remove(sourcePath) ?? null; + }); + ipc.handle(ipcMain, "recents.restore", (_event, document) => { + this.#recents?.restore(document); + }); + ipc.handle(ipcMain, "recents.reveal", (_event, sourcePath) => { + shell.showItemInFolder(sourcePath); + }); + ipc.handle(ipcMain, "recents.locate", async (event, missingPath) => { + const window = this.#requireWindowForWebContents(event.sender); + const locatedPath = await this.#nativeDialogs.openFont(window); + if (!locatedPath) return; + + const opened = await this.#openPathFromWindow(window, locatedPath); + if (opened && path.resolve(locatedPath) !== missingPath) this.#recents?.remove(missingPath); + }); ipc.handle(ipcMain, "document.connect", (event) => { this.#log.info("document connect requested"); const session = this.#fontSessionForSender(event.sender, "document.connect"); @@ -447,6 +511,9 @@ export class App { event.sender.postMessage("session.port", null, [port2]); this.#log.info("font session port sent to renderer"); }); + ipc.handle(ipcMain, "window.ready", (event) => { + this.#presentIfHidden(this.#requireWindowForWebContents(event.sender)); + }); ipc.handle(ipcMain, "window.reopenDocument", async (event) => { const window = this.#requireWindowForWebContents(event.sender); await this.#reopenDocumentWindow(window); @@ -656,17 +723,83 @@ export class App { } async #openWorkspaceFromWindow(opener: Window): Promise { + let openPath: string | null; try { - const openPath = await this.#nativeDialogs.openFont(opener); - if (!openPath) return; + openPath = await this.#nativeDialogs.openFont(opener); + } catch (error) { + this.#log.warn("open dialog failed", error); + await this.#nativeDialogs.showOpenFailure(opener, this.applicationName); + return; + } + if (!openPath) return; - const session = await this.#workspaces.openPath(openPath); - if (this.#focusExistingWorkspaceWindow(opener, session)) return; + await this.#openPathFromWindow(opener, openPath); + } + + /** + * Opens a font path on behalf of a window, showing a native failure when it cannot open. + * + * @returns whether a workspace window now shows the file. + */ + async #openPathFromWindow(opener: Window, sourcePath: string): Promise { + const openerIsLauncher = this.#workspaces.getForBrowserWindow(opener.window) === null; + if (openerIsLauncher) this.#replacedLaunchers.add(opener); + + try { + const session = await this.#workspaces.openPath(sourcePath); + if (this.#focusExistingWorkspaceWindow(opener, session)) return true; this.#openWorkspaceWindow(opener, session); + return true; } catch (error) { this.#log.warn("open document failed", error); + if (openerIsLauncher) this.#restoreLauncherRecents(opener); await this.#nativeDialogs.showOpenFailure(opener, this.applicationName); + return false; + } + } + + #restoreLauncherRecents(launcher: Window): void { + this.#replacedLaunchers.delete(launcher); + if (launcher.window.isDestroyed()) return; + + ipc.send(launcher.window.webContents, "recents.changed", this.#recents?.list() ?? []); + } + + #openRecentFromMenu(sourcePath: string): void { + const opener = this.#windows.activeWindow(); + if (!opener) { + this.#handleOpenPath(sourcePath); + return; + } + + // Menu clicks cannot await; #openPathFromWindow reports its own failures. + void this.#openPathFromWindow(opener, sourcePath); + } + + /** + * Builds a recent file's thumbnail specimen in its session's utility process. + * + * @remarks + * Runs detached from the open or save that triggered it; failures such as + * the session closing mid-build leave the previous thumbnail in place. + */ + async #buildSpecimen(visit: RecentDocumentVisit, session: FontSessionHost): Promise { + try { + const specimen = await session.workspaceProcess.specimen(visit.path); + this.#recents?.setSpecimen(visit, specimen); + } catch (error) { + this.#log.warn("building recent file specimen failed", visit.path, error); + } + } + + #publishRecents(): void { + this.#applicationMenu.refresh(); + + const documents = this.#recents?.list() ?? []; + for (const window of this.#windows.allWindows()) { + if (window.window.isDestroyed() || this.#replacedLaunchers.has(window)) continue; + ipc.send(window.window.webContents, "recents.changed", documents); } } @@ -675,7 +808,9 @@ export class App { if (!existingWindow) return false; existingWindow.focus(); - if (this.#workspaces.getForBrowserWindow(opener.window) === null) opener.close(); + if (this.#workspaces.getForBrowserWindow(opener.window) === null) { + this.#closeReplacedLauncher(opener); + } return true; } @@ -688,7 +823,12 @@ export class App { this.#workspaces.attachWindow(session.workspaceId, workspaceWindow); this.#loadWorkspace(workspaceWindow); - if (closeOpener) opener.close(); + if (closeOpener) this.#closeReplacedLauncher(opener); + } + + #closeReplacedLauncher(launcher: Window): void { + this.#replacedLaunchers.add(launcher); + launcher.close(); } #fontSessionForSender(sender: WebContents, operation: string): FontSessionHost { @@ -716,3 +856,16 @@ export class App { return window; } } + +/** Centres the 4:3 launcher on the primary display, capped to 90% of smaller screens. */ +function launcherBounds(): Rectangle { + const workArea = screen.getPrimaryDisplay().workArea; + const width = Math.min(LAUNCHER_WIDTH, Math.round(workArea.width * LAUNCHER_MAX_SCREEN_SHARE)); + const height = Math.min(LAUNCHER_HEIGHT, Math.round(workArea.height * LAUNCHER_MAX_SCREEN_SHARE)); + return { + x: workArea.x + Math.round((workArea.width - width) / 2), + y: workArea.y + Math.round((workArea.height - height) / 2), + width, + height, + }; +} diff --git a/apps/desktop/src/main/menu/ApplicationMenu.ts b/apps/desktop/src/main/menu/ApplicationMenu.ts index 042b859bd..0ba6c8529 100644 --- a/apps/desktop/src/main/menu/ApplicationMenu.ts +++ b/apps/desktop/src/main/menu/ApplicationMenu.ts @@ -6,6 +6,7 @@ import { fileMenuItems, helpMenuItems, viewMenuItems, + type RecentMenu, } from "./menuItems"; import { commands } from "../commands/Commands"; @@ -22,6 +23,7 @@ const isMac = process.platform === "darwin"; export class ApplicationMenu { readonly #runCommand: (id: CommandId, window?: BrowserWindow) => void; readonly #isCommandEnabled: (id: CommandId, window?: BrowserWindow) => boolean; + readonly #recent: () => RecentMenu; #menu: Menu | null = null; /** @@ -29,13 +31,16 @@ export class ApplicationMenu { * * @param runCommand - executes Shift-owned menu actions against the current window. * @param isCommandEnabled - resolves each command's current native enabled state. + * @param recent - resolves File → Open Recent's current files and actions. */ constructor( runCommand: (id: CommandId, window?: BrowserWindow) => void, isCommandEnabled: (id: CommandId, window?: BrowserWindow) => boolean, + recent: () => RecentMenu, ) { this.#runCommand = runCommand; this.#isCommandEnabled = isCommandEnabled; + this.#recent = recent; } /** Installs the current menu template as Electron's application menu. */ @@ -44,6 +49,13 @@ export class ApplicationMenu { Menu.setApplicationMenu(this.#menu); } + /** Rebuilds the installed menu so dynamic submenus such as Open Recent stay current. */ + refresh(): void { + if (!this.#menu) return; + + this.install(); + } + /** Re-evaluates command capabilities against the active window and session. */ updateCommandStates(): void { if (!this.#menu) return; @@ -170,7 +182,7 @@ export class ApplicationMenu { #fileItems(includeQuit: boolean): MenuItemConstructorOptions[] { const items: MenuItemConstructorOptions[] = [ - ...fileMenuItems(this.#runCommand, this.#isCommandEnabled), + ...fileMenuItems(this.#runCommand, this.#isCommandEnabled, this.#recent()), { type: "separator" }, this.#commandItem("window.close"), ]; diff --git a/apps/desktop/src/main/menu/menuItems.test.ts b/apps/desktop/src/main/menu/menuItems.test.ts index 397a7fb2c..e0aa4ebfb 100644 --- a/apps/desktop/src/main/menu/menuItems.test.ts +++ b/apps/desktop/src/main/menu/menuItems.test.ts @@ -5,12 +5,23 @@ import { editMenuItems, fileMenuItems, helpMenuItems, + openRecentMenuItem, viewMenuItems, } from "./menuItems"; +import type { RecentDocument } from "../../shared/recents"; const run = () => {}; const enabled = () => true; const ids = (items: MenuItemConstructorOptions[]) => items.flatMap(({ id }) => (id ? [id] : [])); +const noRecents = { documents: [], open: () => {}, clear: () => {} }; + +function recent(path: string, missing = false): RecentDocument { + return { path, documentId: null, openedAt: 0, location: "~", missing, specimen: null }; +} + +function submenu(item: MenuItemConstructorOptions): MenuItemConstructorOptions[] { + return item.submenu as MenuItemConstructorOptions[]; +} describe("application command menu items", () => { it("publishes command identity, accelerator, and current capability", () => { @@ -42,6 +53,7 @@ describe("application command menu items", () => { checked.push(id); return id === "file.open"; }, + noRecents, ); expect(checked).toEqual([ @@ -53,6 +65,53 @@ describe("application command menu items", () => { ]); }); + it("opens recent files by path and disables the ones that went missing", () => { + const opened: string[] = []; + let cleared = false; + const items = submenu( + openRecentMenuItem({ + documents: [recent("/fonts/Fraunces.shift"), recent("/gone/London.otf", true)], + open: (path) => opened.push(path), + clear: () => { + cleared = true; + }, + }), + ); + + expect(items.map(({ label, enabled }) => ({ label, enabled }))).toEqual([ + { label: "Fraunces.shift", enabled: true }, + { label: "London.otf", enabled: false }, + { label: undefined, enabled: undefined }, + { label: "Clear Menu", enabled: true }, + ]); + + (items[0].click as () => void)(); + (items[3].click as () => void)(); + expect(opened).toEqual(["/fonts/Fraunces.shift"]); + expect(cleared).toBe(true); + }); + + it("names the parent folder when recent files share a filename", () => { + const items = submenu( + openRecentMenuItem({ + documents: [recent("/fonts/Roman/Font.ufo"), recent("/fonts/Italic/Font.ufo")], + open: () => {}, + clear: () => {}, + }), + ); + + expect(items.slice(0, 2).map(({ label }) => label)).toEqual([ + "Font.ufo — Roman", + "Font.ufo — Italic", + ]); + }); + + it("leaves only a disabled Clear Menu when there are no recent files", () => { + expect(submenu(openRecentMenuItem(noRecents))).toEqual([ + expect.objectContaining({ label: "Clear Menu", enabled: false }), + ]); + }); + it("places Settings in Edit only where the platform has no application menu", () => { expect(ids(editMenuItems(false, run, enabled))).not.toContain("app.showSettings"); expect(ids(editMenuItems(true, run, enabled)).at(-1)).toBe("app.showSettings"); diff --git a/apps/desktop/src/main/menu/menuItems.ts b/apps/desktop/src/main/menu/menuItems.ts index 9c4779913..777108f5a 100644 --- a/apps/desktop/src/main/menu/menuItems.ts +++ b/apps/desktop/src/main/menu/menuItems.ts @@ -1,6 +1,14 @@ import type { MenuItemConstructorOptions } from "electron"; import { commandShortcuts, toElectronAccelerator, type CommandId } from "../../shared/commands"; import { commands } from "../commands/Commands"; +import { pathBasename, recentFolderLabels, type RecentDocument } from "../../shared/recents"; + +/** Recent files and actions shown in File → Open Recent. */ +export type RecentMenu = { + documents: readonly RecentDocument[]; + open: (path: string) => void; + clear: () => void; +}; export function commandMenuItem( id: CommandId, @@ -24,10 +32,12 @@ export function commandMenuItem( export function fileMenuItems( runCommand: (id: CommandId) => void, isCommandEnabled: (id: CommandId) => boolean, + recent: RecentMenu, ): MenuItemConstructorOptions[] { return [ commandMenuItem("file.new", runCommand, isCommandEnabled), commandMenuItem("file.open", runCommand, isCommandEnabled), + openRecentMenuItem(recent), { type: "separator" }, commandMenuItem("file.save", runCommand, isCommandEnabled), commandMenuItem("file.saveAs", runCommand, isCommandEnabled), @@ -39,6 +49,35 @@ export function fileMenuItems( ]; } +/** + * Builds File → Open Recent: one item per recent file, then Clear Menu. + * + * @remarks + * Files that share a name are suffixed with the parent folder that tells them + * apart. Missing files stay listed but disabled until they are located or cleared. + */ +export function openRecentMenuItem(recent: RecentMenu): MenuItemConstructorOptions { + const folders = recentFolderLabels(recent.documents.map((document) => document.path)); + const documentItems = recent.documents.map((document): MenuItemConstructorOptions => { + const name = pathBasename(document.path); + const folder = folders.get(document.path); + return { + label: folder ? `${name} — ${folder}` : name, + enabled: !document.missing, + click: () => recent.open(document.path), + }; + }); + + return { + label: "Open Recent", + submenu: [ + ...documentItems, + ...(documentItems.length > 0 ? [{ type: "separator" as const }] : []), + { label: "Clear Menu", enabled: documentItems.length > 0, click: () => recent.clear() }, + ], + }; +} + /** * Builds the Edit menu's command items. * diff --git a/apps/desktop/src/main/recents/RecentDocuments.test.ts b/apps/desktop/src/main/recents/RecentDocuments.test.ts new file mode 100644 index 000000000..8e6137f76 --- /dev/null +++ b/apps/desktop/src/main/recents/RecentDocuments.test.ts @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RECENT_DOCUMENTS_LIMIT, RecentDocuments } from "./RecentDocuments"; + +describe("RecentDocuments", () => { + let root: string; + let storePath: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "shift-recents-")); + storePath = path.join(root, "recent-documents.json"); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function font(name: string): string { + const filePath = path.join(root, name); + fs.writeFileSync(filePath, ""); + return filePath; + } + + const paths = (recents: RecentDocuments) => recents.list().map((document) => document.path); + + it("lists files newest first and moves a reopened file to the front", () => { + const recents = new RecentDocuments(storePath); + const a = font("A.otf"); + const b = font("B.otf"); + + recents.record({ path: a, documentId: null }, 1); + recents.record({ path: b, documentId: null }, 2); + recents.record({ path: a, documentId: null }, 3); + + expect(paths(recents)).toEqual([a, b]); + }); + + it("replaces a moved .shift document's old entry by DocumentId", () => { + const recents = new RecentDocuments(storePath); + const original = font("Fraunces.shift"); + const moved = font("Fraunces Copy.shift"); + + recents.record({ path: original, documentId: "doc-1" }, 1); + recents.record({ path: moved, documentId: "doc-1" }, 2); + + expect(paths(recents)).toEqual([moved]); + }); + + it(`keeps at most ${RECENT_DOCUMENTS_LIMIT} files, dropping the oldest`, () => { + const recents = new RecentDocuments(storePath); + for (let index = 0; index <= RECENT_DOCUMENTS_LIMIT; index++) { + recents.record({ path: font(`F${index}.otf`), documentId: null }, index); + } + + expect(paths(recents)).toHaveLength(RECENT_DOCUMENTS_LIMIT); + expect(paths(recents)).not.toContain(path.join(root, "F0.otf")); + }); + + it("survives a relaunch", () => { + const a = font("A.glyphs"); + new RecentDocuments(storePath).record({ path: a, documentId: null }, 5); + + expect(new RecentDocuments(storePath).list()).toEqual([ + { path: a, documentId: null, openedAt: 5, location: root, missing: false, specimen: null }, + ]); + }); + + it("starts empty when the stored list is unreadable", () => { + fs.writeFileSync(storePath, "{not json"); + + expect(new RecentDocuments(storePath).list()).toEqual([]); + }); + + it("marks files that no longer exist as missing", () => { + const recents = new RecentDocuments(storePath); + const gone = font("Gone.ttf"); + recents.record({ path: gone, documentId: null }, 1); + + fs.rmSync(gone); + + expect(recents.list()[0].missing).toBe(true); + }); + + it("restores a removed file to its original position", () => { + const recents = new RecentDocuments(storePath); + const [a, b, c] = ["A.otf", "B.otf", "C.otf"].map(font); + recents.record({ path: a, documentId: null }, 1); + recents.record({ path: b, documentId: null }, 2); + recents.record({ path: c, documentId: null }, 3); + + const removed = recents.remove(b); + expect(paths(recents)).toEqual([c, a]); + + recents.restore(removed!); + expect(paths(recents)).toEqual([c, b, a]); + }); + + const specimen = { + text: "Ag", + outline: "M0 0L10 0L10 -10Z", + viewBox: [0, -10, 10, 10], + rightToLeft: false, + }; + + it("caches a specimen until a font file changes outside Shift", () => { + const recents = new RecentDocuments(storePath); + const otf = font("London.otf"); + recents.record({ path: otf, documentId: null }, 1); + expect(recents.needsSpecimen({ path: otf, documentId: null })).toBe(true); + + recents.setSpecimen({ path: otf, documentId: null }, specimen); + expect(new RecentDocuments(storePath).list()[0].specimen).toEqual(specimen); + expect(recents.needsSpecimen({ path: otf, documentId: null })).toBe(false); + + const later = new Date(Date.now() + 60_000); + fs.utimesSync(otf, later, later); + expect(recents.list()[0].specimen).toBeNull(); + expect(recents.needsSpecimen({ path: otf, documentId: null })).toBe(true); + }); + + it("keeps a .shift document's specimen when it is reopened from a new path", () => { + const recents = new RecentDocuments(storePath); + const original = font("Fraunces.shift"); + const moved = font("Moved.shift"); + recents.record({ path: original, documentId: "doc-1" }, 1); + recents.setSpecimen({ path: original, documentId: "doc-1" }, specimen); + + recents.record({ path: moved, documentId: "doc-1" }, 2); + + expect(recents.list()).toEqual([ + { path: moved, documentId: "doc-1", openedAt: 2, location: root, missing: false, specimen }, + ]); + }); + + it("shows a file's folder with the home directory shortened to ~", () => { + const home = path.dirname(root); + const recents = new RecentDocuments(storePath, home); + recents.record({ path: font("A.otf"), documentId: null }, 1); + + expect(recents.list()[0].location).toBe(`~/${path.basename(root)}`); + }); + + it("clears every file", () => { + const recents = new RecentDocuments(storePath); + recents.record({ path: font("A.otf"), documentId: null }, 1); + + recents.clear(); + + expect(new RecentDocuments(storePath).list()).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/recents/RecentDocuments.ts b/apps/desktop/src/main/recents/RecentDocuments.ts new file mode 100644 index 000000000..45265f963 --- /dev/null +++ b/apps/desktop/src/main/recents/RecentDocuments.ts @@ -0,0 +1,268 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { RecentDocument, RecentDocumentVisit } from "../../shared/recents"; +import type { Specimen } from "../../shared/workspace/protocol"; + +/** Most files kept in recents; older opens fall off the end. */ +export const RECENT_DOCUMENTS_LIMIT = 20; + +/** + * Revision of the specimen choice rules. Bump it when they change so cached + * thumbnails built under older rules are rebuilt on next open. + */ +const SPECIMEN_REVISION = 1; + +/** A built specimen and the file modification time it was built from. */ +type Thumbnail = { + specimen: Specimen | null; + modifiedAt: number; + revision?: number; +}; + +type RecentEntry = { + path: string; + documentId: string | null; + openedAt: number; + thumbnail?: Thumbnail; +}; + +/** + * Main-owned list of files Shift has opened, newest first. + * + * @remarks + * Entries are keyed by `DocumentId` when the file is a native `.shift` + * document, so a moved or renamed document replaces its old entry, and by + * canonical path otherwise. The list persists as JSON after every change; an + * unreadable file starts an empty list rather than failing app startup. + * + * Each entry may cache the thumbnail specimen built for it. A `.shift` + * document's specimen stays valid until Shift replaces it; any other file's + * specimen is dropped once the file's modification time changes. + */ +export class RecentDocuments { + readonly #filePath: string; + readonly #homeDirectory: string; + readonly #listeners = new Set<() => void>(); + #entries: RecentEntry[]; + + /** + * Loads recents persisted at `filePath`. + * + * @param filePath - JSON file that stores the list across launches. + */ + constructor(filePath: string, homeDirectory: string = os.homedir()) { + this.#filePath = filePath; + this.#homeDirectory = homeDirectory; + this.#entries = readEntries(filePath); + } + + /** + * Returns every recent file, newest first, with a fresh existence check. + * + * @returns a new array; mutating it does not change the store. + */ + list(): RecentDocument[] { + return this.#entries.map((entry) => { + const modifiedAt = modificationTime(entry.path); + return { + path: entry.path, + documentId: entry.documentId, + openedAt: entry.openedAt, + location: displayLocation(entry.path, this.#homeDirectory), + missing: modifiedAt === null, + specimen: currentThumbnail(entry, modifiedAt)?.specimen ?? null, + }; + }); + } + + /** + * Checks whether a file lacks a current thumbnail specimen. + * + * @param visit - File to check; it need not be in recents. + */ + needsSpecimen(visit: RecentDocumentVisit): boolean { + const entry = this.#entries.find((candidate) => sameDocument(candidate, visit)); + if (!entry) return false; + + return currentThumbnail(entry, modificationTime(entry.path)) === null; + } + + /** + * Caches the thumbnail specimen built for a recent file. + * + * @param visit - File the specimen was built from; ignored when it left recents meanwhile. + * @param specimen - Built specimen, or null when the font draws nothing usable. + */ + setSpecimen(visit: RecentDocumentVisit, specimen: Specimen | null): void { + const entry = this.#entries.find((candidate) => sameDocument(candidate, visit)); + const modifiedAt = modificationTime(visit.path); + if (!entry || modifiedAt === null) return; + + this.#entries = this.#entries.map((candidate) => + candidate === entry + ? { ...candidate, thumbnail: { specimen, modifiedAt, revision: SPECIMEN_REVISION } } + : candidate, + ); + this.#changed(); + } + + /** + * Moves a file to the front of recents. + * + * @param visit - Opened path plus its `DocumentId`, when it has one. + * @param openedAt - Epoch milliseconds of the open. + */ + record(visit: RecentDocumentVisit, openedAt: number = Date.now()): void { + const previous = this.#entries.find((entry) => sameDocument(entry, visit)); + const remaining = this.#entries.filter((entry) => !sameDocument(entry, visit)); + const recorded: RecentEntry = { path: visit.path, documentId: visit.documentId, openedAt }; + if (previous?.thumbnail) recorded.thumbnail = previous.thumbnail; + + this.#entries = [recorded, ...remaining].slice(0, RECENT_DOCUMENTS_LIMIT); + this.#changed(); + } + + /** + * Removes one file from recents. + * + * @param filePath - Path of the entry to remove. + * @returns the removed entry, or null when no entry has that path. + */ + remove(filePath: string): RecentDocument | null { + const removed = this.list().find((entry) => entry.path === filePath) ?? null; + if (!removed) return null; + + this.#entries = this.#entries.filter((entry) => entry.path !== filePath); + this.#changed(); + return removed; + } + + /** + * Puts a removed entry back in its original time order. + * + * @param document - Entry previously returned by {@link remove}. + */ + restore(document: RecentDocument): void { + const remaining = this.#entries.filter((entry) => !sameDocument(entry, document)); + const restored: RecentEntry = { + path: document.path, + documentId: document.documentId, + openedAt: document.openedAt, + }; + const modifiedAt = modificationTime(document.path); + if (document.specimen && modifiedAt !== null) { + restored.thumbnail = { + specimen: document.specimen, + modifiedAt, + revision: SPECIMEN_REVISION, + }; + } + this.#entries = [...remaining, restored] + .sort((a, b) => b.openedAt - a.openedAt) + .slice(0, RECENT_DOCUMENTS_LIMIT); + this.#changed(); + } + + /** Removes every entry. */ + clear(): void { + if (this.#entries.length === 0) return; + + this.#entries = []; + this.#changed(); + } + + /** + * Subscribes to list changes. + * + * @param listener - Called after each change has been persisted. + * @returns a function that removes this listener. + */ + onChanged(listener: () => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + #changed(): void { + writeEntries(this.#filePath, this.#entries); + for (const listener of this.#listeners) listener(); + } +} + +function sameDocument(entry: RecentEntry, visit: RecentDocumentVisit): boolean { + if (entry.path === visit.path) return true; + return visit.documentId !== null && entry.documentId === visit.documentId; +} + +/** Returns the file's folder with a leading home directory shortened to `~`. */ +function displayLocation(filePath: string, homeDirectory: string): string { + const folder = path.dirname(filePath); + if (folder === homeDirectory) return "~"; + if (folder.startsWith(homeDirectory + path.sep)) { + return `~${folder.slice(homeDirectory.length)}`; + } + return folder; +} + +function modificationTime(filePath: string): number | null { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return null; + } +} + +function currentThumbnail(entry: RecentEntry, modifiedAt: number | null): Thumbnail | null { + const thumbnail = entry.thumbnail; + if (!thumbnail || modifiedAt === null) return null; + if (thumbnail.revision !== SPECIMEN_REVISION) return null; + if (entry.documentId !== null) return thumbnail; + + return thumbnail.modifiedAt === modifiedAt ? thumbnail : null; +} + +function readEntries(filePath: string): RecentEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return []; + } + + if (!Array.isArray(parsed)) return []; + return parsed.filter(isRecentEntry).slice(0, RECENT_DOCUMENTS_LIMIT); +} + +function writeEntries(filePath: string, entries: readonly RecentEntry[]): void { + const temporaryPath = `${filePath}.tmp`; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(temporaryPath, JSON.stringify(entries, null, 2)); + fs.renameSync(temporaryPath, filePath); + } catch (error) { + console.warn("failed to persist recent documents", error); + } +} + +function isRecentEntry(value: unknown): value is RecentEntry { + if (typeof value !== "object" || value === null) return false; + + const entry = value as Record; + const documentIdValid = entry.documentId === null || typeof entry.documentId === "string"; + const thumbnailValid = entry.thumbnail === undefined || isThumbnail(entry.thumbnail); + return ( + typeof entry.path === "string" && + typeof entry.openedAt === "number" && + documentIdValid && + thumbnailValid + ); +} + +function isThumbnail(value: unknown): value is Thumbnail { + if (typeof value !== "object" || value === null) return false; + + const thumbnail = value as Record; + return typeof thumbnail.modifiedAt === "number" && typeof thumbnail.specimen === "object"; +} diff --git a/apps/desktop/src/main/windows/Window.ts b/apps/desktop/src/main/windows/Window.ts index 35f638525..34701cd88 100644 --- a/apps/desktop/src/main/windows/Window.ts +++ b/apps/desktop/src/main/windows/Window.ts @@ -91,13 +91,15 @@ export class Window { } present(): void { - this.#window.show(); - + // Maximising a hidden window shows it already at full size, so it never + // flashes at its normal bounds first. if (this.#maximiseOnPresent) { this.#maximiseOnPresent = false; this.#window.maximize(); } + this.#window.show(); + this.#window.focus(); } diff --git a/apps/desktop/src/main/workspace/WorkspaceManager.ts b/apps/desktop/src/main/workspace/WorkspaceManager.ts index 4b68c1efb..8da25f6ed 100644 --- a/apps/desktop/src/main/workspace/WorkspaceManager.ts +++ b/apps/desktop/src/main/workspace/WorkspaceManager.ts @@ -12,6 +12,7 @@ import { WorkspaceProcess } from "./WorkspaceProcess"; import { FontSessionHost, type FontSessionId } from "./FontSessionHost"; import { DocumentSessionIndex } from "./DocumentSessionIndex"; import { isConvertiblePreviewPath } from "../../shared/workspace/previewConversion"; +import type { RecentDocumentVisit } from "../../shared/recents"; /** Provides app-owned values required when a workspace session is created. */ export interface WorkspaceManagerOptions { @@ -19,6 +20,10 @@ export interface WorkspaceManagerOptions { readonly applicationName: () => string; readonly nativeDialogs: NativeDialogs; readonly onSessionCrashed?: (session: FontSessionHost) => void; + /** Called when a file is opened, or when a document gains a new path through Save As. */ + readonly onDocumentVisited?: (visit: RecentDocumentVisit, session: FontSessionHost) => void; + /** Called after an authored document is saved in place. */ + readonly onDocumentSaved?: (visit: RecentDocumentVisit, session: FontSessionHost) => void; } /** @@ -35,6 +40,8 @@ export class WorkspaceManager { readonly #applicationName: () => string; readonly #nativeDialogs: NativeDialogs; readonly #onSessionCrashed: (session: FontSessionHost) => void; + readonly #onDocumentVisited: (visit: RecentDocumentVisit, session: FontSessionHost) => void; + readonly #onDocumentSaved: (visit: RecentDocumentVisit, session: FontSessionHost) => void; readonly #sessionsById = new Map(); readonly #sessionIdByWindowId = new Map(); readonly #documentSessions = new DocumentSessionIndex(); @@ -50,6 +57,8 @@ export class WorkspaceManager { this.#applicationName = options.applicationName; this.#nativeDialogs = options.nativeDialogs; this.#onSessionCrashed = options.onSessionCrashed ?? (() => {}); + this.#onDocumentVisited = options.onDocumentVisited ?? (() => {}); + this.#onDocumentSaved = options.onDocumentSaved ?? (() => {}); } /** @@ -132,6 +141,10 @@ export class WorkspaceManager { const existingBeforeOpen = this.#sessionForDocument(identity); if (existingBeforeOpen) { workspaceProcess.stop(); + this.#onDocumentVisited( + { path: identity.canonicalPath, documentId: identity.documentId }, + existingBeforeOpen, + ); return existingBeforeOpen; } @@ -315,9 +328,35 @@ export class WorkspaceManager { throw error; } + this.#recordDocumentPaths(session, state); return session; } + /** + * Reports the document's path whenever it gains one, and each save in place, + * as later state changes arrive. + */ + #recordDocumentPaths(session: FontSessionHost, state: WorkspaceDocumentState): void { + let recordedPath: string | null = null; + let dirty = state.dirty; + const observe = (next: WorkspaceDocumentState | null) => { + if (!next?.canonicalPath) return; + + const visit = { path: next.canonicalPath, documentId: next.documentId }; + const saved = dirty && !next.dirty; + dirty = next.dirty; + if (next.canonicalPath !== recordedPath) { + recordedPath = next.canonicalPath; + this.#onDocumentVisited(visit, session); + return; + } + if (saved) this.#onDocumentSaved(visit, session); + }; + + observe(state); + session.workspaceProcess.onDocumentChanged(observe); + } + async #restoreRecovery(recovery: WorkspaceRecovery): Promise { switch (recovery.kind) { case "saved": @@ -381,9 +420,11 @@ export class WorkspaceManager { try { await workspaceProcess.whenReady(); const state = await workspaceProcess.openFontSource(sourcePath); + const visit = { path: state.canonicalPath, documentId: null }; const existing = this.get(state.sessionId); if (existing) { workspaceProcess.stop(); + this.#onDocumentVisited(visit, existing); return existing; } @@ -394,6 +435,7 @@ export class WorkspaceManager { workspaceProcess, }); this.register(session); + this.#onDocumentVisited(visit, session); return session; } catch (error) { workspaceProcess.stop(); diff --git a/apps/desktop/src/main/workspace/WorkspaceProcess.ts b/apps/desktop/src/main/workspace/WorkspaceProcess.ts index 20c4d388e..50b8868b2 100644 --- a/apps/desktop/src/main/workspace/WorkspaceProcess.ts +++ b/apps/desktop/src/main/workspace/WorkspaceProcess.ts @@ -6,6 +6,7 @@ import type { ShellCallMap, ShellEventMap, WorkspaceDocumentIdentity, + Specimen, WorkspaceDocumentState, WorkspaceRecovery, } from "../../shared/workspace/protocol"; @@ -217,6 +218,17 @@ export class WorkspaceProcess { return this.#requireChannel().call("document.state", undefined); } + /** + * Builds the recent-files thumbnail specimen on the utility's worker thread. + * + * @param path - Font file to read when the process holds a preview rather than a document. + * @returns null when the font draws nothing usable. + * @throws {Error} when the process is not running, or the font cannot be read or compiled. + */ + specimen(path: string): Promise { + return this.#requireChannel().call("workspace.specimen", { path }); + } + #trackReady(proc: UtilityProcess, channel: Channel): Promise { const ready = new Promise((resolve, reject) => { const unlisten = channel.listen("ready", () => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 91f897f71..2fb111f27 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -22,6 +22,7 @@ const shiftHost: ShiftHost = { }, window: { reopenDocument: invoke(ipcRenderer, "window.reopenDocument"), + ready: invoke(ipcRenderer, "window.ready"), }, errors: { reportRenderer: invoke(ipcRenderer, "errors.reportRenderer"), @@ -38,6 +39,15 @@ const shiftHost: ShiftHost = { ui: { onZoomChanged: listen(ipcRenderer, "ui.zoomChanged"), }, + recents: { + list: invoke(ipcRenderer, "recents.list"), + open: invoke(ipcRenderer, "recents.open"), + remove: invoke(ipcRenderer, "recents.remove"), + restore: invoke(ipcRenderer, "recents.restore"), + reveal: invoke(ipcRenderer, "recents.reveal"), + locate: invoke(ipcRenderer, "recents.locate"), + onChanged: listen(ipcRenderer, "recents.changed"), + }, clipboard: { writeText: invoke(ipcRenderer, "clipboard.writeText"), readText: invoke(ipcRenderer, "clipboard.readText"), diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 00abce426..c6b8ecbb8 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -36,6 +36,7 @@ --color-sidebar-icon: #585858; --color-accent: #0c92f4; + --color-on-accent: #ffffff; /* Status and action colors */ --color-error: #dc2626; @@ -64,6 +65,10 @@ --text-xs: 0.625rem /* 10px */; /* Line box from the font's own ascent and descent, for previewing any script. */ --leading-normal: normal; + + /* Shadow colours for card-shadow; themes darken them on dark palettes. */ + --color-shadow-ambient: rgba(50, 50, 93, 0.25); + --color-shadow-key: rgba(0, 0, 0, 0.3); } /* Shift Light fallback palette consumed by the 2D and WebGL editor renderers. */ @@ -109,6 +114,13 @@ --editor-text-component-overlay-hover-b: rgba(255, 151, 186, 0.4); } +/* Lifts a bordered card off the page with theme-aware shadow colours. */ +@utility card-shadow { + box-shadow: + 0 2px 5px -1px var(--color-shadow-ambient), + 0 1px 3px -1px var(--color-shadow-key); +} + /* Hide scrollbar while preserving scroll behavior */ @utility scrollbar-hidden { scrollbar-width: none; diff --git a/apps/desktop/src/renderer/src/app/branding.ts b/apps/desktop/src/renderer/src/app/branding.ts index 455089c15..296c72940 100644 --- a/apps/desktop/src/renderer/src/app/branding.ts +++ b/apps/desktop/src/renderer/src/app/branding.ts @@ -1,9 +1,9 @@ import releaseAppIcon from "@/assets/app-icon.png"; import nightlyAppIcon from "@/assets/app-icon-nightly.png"; -import ReleaseLauncherLogo from "@/assets/launcher-logo.svg"; -import NightlyLauncherLogo from "@/assets/launcher-logo-nightly.svg"; +import ReleaseLauncherLockup from "@/assets/launcher-lockup.svg"; +import NightlyLauncherLockup from "@/assets/launcher-lockup-nightly.svg"; import { shiftDistribution } from "./release"; export const appIcon = shiftDistribution === "nightly" ? nightlyAppIcon : releaseAppIcon; -export const LauncherLogo = - shiftDistribution === "nightly" ? NightlyLauncherLogo : ReleaseLauncherLogo; +export const LauncherLockup = + shiftDistribution === "nightly" ? NightlyLauncherLockup : ReleaseLauncherLockup; diff --git a/apps/desktop/src/renderer/src/assets/launcher-lockup-nightly.svg b/apps/desktop/src/renderer/src/assets/launcher-lockup-nightly.svg new file mode 100644 index 000000000..d69498b35 --- /dev/null +++ b/apps/desktop/src/renderer/src/assets/launcher-lockup-nightly.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/renderer/src/assets/launcher-lockup.svg b/apps/desktop/src/renderer/src/assets/launcher-lockup.svg new file mode 100644 index 000000000..ac06fb0b7 --- /dev/null +++ b/apps/desktop/src/renderer/src/assets/launcher-lockup.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/renderer/src/assets/launcher-logo-nightly.svg b/apps/desktop/src/renderer/src/assets/launcher-logo-nightly.svg deleted file mode 100644 index cfb36cbfc..000000000 --- a/apps/desktop/src/renderer/src/assets/launcher-logo-nightly.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/src/assets/launcher-logo.svg b/apps/desktop/src/renderer/src/assets/launcher-logo.svg deleted file mode 100644 index b37abd7ad..000000000 --- a/apps/desktop/src/renderer/src/assets/launcher-logo.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx new file mode 100644 index 000000000..3d1a462d1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx @@ -0,0 +1,253 @@ +import { Fragment, useLayoutEffect, useRef, useState } from "react"; +import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from "@shift/ui"; +import { pathBasename, recentOpenedLabel, type RecentDocument } from "@shared/recents"; +import { RecentFileMenu } from "./RecentFileMenu"; +import { useAltKeyHeld } from "./useRecentDocuments"; +import type { RecentFileActions } from "./useRecentFileActions"; +import { filenameWrapChunks, middleTruncatedName, middleTruncationLimit } from "./recentFileName"; + +interface RecentFileItemProps { + document: RecentDocument; + /** Parent folder that distinguishes files sharing this filename. */ + folder: string | undefined; + now: number; + actions: RecentFileActions; +} + +/** Draws a recent file's cached specimen, or nothing when it has none yet. */ +const RecentSpecimen = ({ + document, + className, +}: { + document: RecentDocument; + className?: string; +}) => { + const specimen = document.specimen; + if (!specimen) return null; + + const alignment = specimen.rightToLeft ? "xMaxYMid meet" : "xMidYMid meet"; + return ( + + ); +}; + +const NAME_CLASS = "line-clamp-2 wrap-anywhere"; + +/** + * Filename that wraps to at most two lines and, when it still overflows, + * cuts its middle so the extension stays visible. + * + * @remarks + * An invisible copy with the same styles measures each candidate; the visible + * label only re-renders with the longest one that fits. + */ +const RecentFileName = ({ name, className }: { name: string; className?: string }) => { + const measureRef = useRef(null); + const [width, setWidth] = useState(0); + const [label, setLabel] = useState(name); + + useLayoutEffect(() => { + const container = measureRef.current?.parentElement; + if (!container) return undefined; + + const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useLayoutEffect(() => { + const measure = measureRef.current; + if (!measure) return; + + setLabel(longestFittingName(name, measure)); + }, [name, width]); + + return ( + + + {filenameWrapChunks(label).map((chunk, index) => ( + + {index > 0 && } + {chunk} + + ))} + + + ); +}; + +function longestFittingName(name: string, measure: HTMLElement): string { + const fits = (text: string) => { + const nodes = filenameWrapChunks(text).flatMap((chunk, index) => + index > 0 ? [window.document.createElement("wbr"), chunk] : [chunk], + ); + measure.replaceChildren(...nodes); + return measure.scrollHeight <= measure.clientHeight; + }; + if (fits(name)) return name; + + let low = 0; + let high = middleTruncationLimit(name) - 1; + let best = 0; + while (low <= high) { + const headLength = Math.floor((low + high) / 2); + if (fits(middleTruncatedName(name, headLength))) { + best = headLength; + low = headLength + 1; + } else { + high = headLength - 1; + } + } + return middleTruncatedName(name, best); +} + +function openedLine(document: RecentDocument, folder: string | undefined, now: number): string { + const when = document.missing + ? "Not found" + : `Opened ${recentOpenedLabel(document.openedAt, now)}`; + return folder ? `${folder} · ${when}` : when; +} + +const MissingActions = ({ + document, + actions, +}: Pick) => ( +
+ + +
+); + +const CARD_CLASS = + "group relative flex min-w-0 flex-col overflow-hidden border border-line/20 bg-background card-shadow transition-shadow hover:shadow-md"; +const CARD_BUTTON_CLASS = + "absolute inset-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"; +const HOVER_REVEAL_CLASS = + "relative shrink-0 self-center opacity-0 group-hover:opacity-100 group-has-focus-visible:opacity-100 data-[popup-open]:opacity-100"; + +/** + * Grid card for one recent file: specimen, filename, and when it was opened. + * + * @remarks + * The specimen area stays empty until cached thumbnails exist. The full path + * lives in the tooltip and the ⋯ menu, never on the card. + */ + +export const RecentFileCard = ({ document, folder, now, actions }: RecentFileItemProps) => { + const name = pathBasename(document.path); + const activate = document.missing ? actions.locate : actions.open; + + return ( +
  • + + +
  • + ); +}; + +/** A row's filename; holding Alt prefixes its folder in a lighter shade, forming the path. */ +const RecentFileRowName = ({ document }: { document: RecentDocument }) => { + const showLocation = useAltKeyHeld(); + + return ( + + {showLocation && {document.location}/} + {pathBasename(document.path)} + + ); +}; + +/** + * List row for one recent file: specimen chip, name, and when it was opened. + * Holding Alt prefixes each name with its folder, forming the path. + */ +export const RecentFileRow = ({ document, now, actions }: RecentFileItemProps) => { + const activate = document.missing ? actions.locate : actions.open; + + return ( +
  • + + {document.missing && } + +
  • + ); +}; diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFileMenu.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFileMenu.tsx new file mode 100644 index 000000000..7489200fa --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFileMenu.tsx @@ -0,0 +1,64 @@ +import { + Button, + Menu, + MenuItem, + MenuPopup, + MenuPortal, + MenuPositioner, + MenuSeparator, + MenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@shift/ui"; +import type { RecentDocument } from "@shared/recents"; +import VerticalEllipsis from "@/assets/general/vertical-ellipsis.svg"; +import { getShiftHost } from "@/host/shiftHost"; +import type { RecentFileActions } from "./useRecentFileActions"; + +interface RecentFileMenuProps { + document: RecentDocument; + actions: RecentFileActions; + className?: string; +} + +/** The ⋯ menu for one recent file: reveal, copy path, remove. */ +export const RecentFileMenu = ({ document, actions, className }: RecentFileMenuProps) => { + const revealLabel = getShiftHost().platform === "darwin" ? "Reveal in Finder" : "Show in Folder"; + + return ( + + + + + } + > + + + + More actions + + + + + {document.missing ? ( + actions.locate(document)}>Locate… + ) : ( + actions.reveal(document)}>{revealLabel} + )} + actions.copyPath(document)}>Copy Path + + actions.remove(document)}>Remove from Recents + + + + + ); +}; diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx new file mode 100644 index 000000000..515e6b638 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx @@ -0,0 +1,282 @@ +import { useMemo, useState } from "react"; +import { + ArrowRight, + Button, + Separator, + LayoutGrid, + List, + ToastProvider, + ToastRoot, + ToastTitle, + ToastViewport, + Toggle, + ToggleGroup, + Tooltip, + TooltipContent, + TooltipTrigger, + useToastManager, +} from "@shift/ui"; +import { recentFolderLabels, type RecentDocument } from "@shared/recents"; +import { RecentFileCard, RecentFileRow } from "./RecentFileItem"; +import { getShiftHost } from "@/host/shiftHost"; +import { useMinuteClock } from "./useRecentDocuments"; +import { + useRecentFileActions, + type RecentFileActions, + type RemovedRecentToast, +} from "./useRecentFileActions"; + +type RecentView = "grid" | "list"; + +/** Recent files shown before "View all": two full grid rows. */ +const COLLAPSED_COUNT = 8; +const UNDO_TIMEOUT_MS = 6000; +const VIEW_STORAGE_KEY = "launcher.recents.view"; + +interface RecentFilesProps { + documents: readonly RecentDocument[]; +} + +/** + * Launcher section: Open Font plus recently opened files as a grid or list. + * + * @remarks + * Newest first, showing eight until "View all" is chosen. New Font and Open + * Font sit in the header. The grid or list choice is remembered per browser + * profile. + */ +export const RecentFiles = ({ documents }: RecentFilesProps) => ( + + + + + + +); + +const RecentFilesSection = ({ documents }: RecentFilesProps) => { + const actions = useRecentFileActions(); + const now = useMinuteClock(); + const [expanded, setExpanded] = useState(false); + const [view, setView] = useStoredChoice(VIEW_STORAGE_KEY, ["grid", "list"], "grid"); + + const folders = useMemo( + () => recentFolderLabels(documents.map((document) => document.path)), + [documents], + ); + const collapsed = !expanded && documents.length > COLLAPSED_COUNT; + const shown = collapsed ? documents.slice(0, COLLAPSED_COUNT) : documents; + + return ( +
    +
    +

    + Recent +

    +
    + + + + +
    +
    +
    + + {collapsed && ( + + )} +
    +
    + ); +}; + +interface RecentFileCollectionProps { + documents: readonly RecentDocument[]; + folders: Map; + now: number; + view: RecentView; + actions: RecentFileActions; +} + +const RecentFileCollection = ({ + documents, + folders, + now, + view, + actions, +}: RecentFileCollectionProps) => { + if (documents.length === 0) { + return

    No recent fonts

    ; + } + + if (view === "list") { + return ( +
      + {documents.map((document) => ( + + ))} +
    + ); + } + + return ( +
      + {documents.map((document) => ( + + ))} +
    + ); +}; + +const ViewToggle = ({ + view, + onChange, +}: { + view: RecentView; + onChange: (view: RecentView) => void; +}) => ( + { + const next = pressed.at(0); + if (next === "grid" || next === "list") onChange(next); + }} + > + + + + + + + +); + +const ViewToggleItem = ({ + value, + label, + children, +}: { + value: RecentView; + label: string; + children: React.ReactNode; +}) => ( + + + + {children} + + + {label} + +); + +const RemovedRecentToasts = () => { + const { toasts, close } = useToastManager(); + const actions = useRecentFileActions(); + + return toasts.map((toast) => { + const removed = (toast.data as RemovedRecentToast | undefined)?.document; + + const undo = async () => { + close(toast.id); + if (removed) await actions.undoRemove(removed); + }; + + return ( + +
    + {String(toast.title)} + {removed && ( + + )} +
    +
    + ); + }); +}; + +async function openFont(): Promise { + try { + await getShiftHost().commands.run("file.open"); + } catch (error) { + console.error("opening a font failed", error); + } +} + +async function createFont(): Promise { + try { + await getShiftHost().commands.run("file.new"); + } catch (error) { + console.error("new font failed", error); + } +} + +function useStoredChoice( + key: string, + options: readonly T[], + fallback: T, +): [T, (value: T) => void] { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key); + return options.find((option) => option === stored) ?? fallback; + } catch { + return fallback; + } + }); + + const update = (next: T) => { + setValue(next); + try { + localStorage.setItem(key, next); + } catch { + // Remembering the choice is a convenience; the in-memory value still applies. + } + }; + + return [value, update]; +} diff --git a/apps/desktop/src/renderer/src/components/launcher/recentFileName.test.ts b/apps/desktop/src/renderer/src/components/launcher/recentFileName.test.ts new file mode 100644 index 000000000..90069708f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/recentFileName.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { filenameWrapChunks, middleTruncatedName } from "./recentFileName"; + +describe("recent file names", () => { + it("wraps before the extension and after hyphens and underscores", () => { + expect(filenameWrapChunks("ScienceGothic_Condensed-Bold.ttf")).toEqual([ + "ScienceGothic_", + "Condensed-", + "Bold", + ".ttf", + ]); + }); + + it("cuts the middle of a name but keeps the end of the stem and the extension", () => { + expect(middleTruncatedName("OpenSans-CondensedItalic.shift", 9)).toBe("OpenSans-…alic.shift"); + }); + + it("leaves a name whole when the kept head already reaches the tail", () => { + expect(middleTruncatedName("Ag.otf", 10)).toBe("Ag.otf"); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/launcher/recentFileName.ts b/apps/desktop/src/renderer/src/components/launcher/recentFileName.ts new file mode 100644 index 000000000..0d56e9193 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/recentFileName.ts @@ -0,0 +1,32 @@ +/** Characters kept from the end of the stem when the middle of a name is cut. */ +const TAIL_STEM_CHARACTERS = 4; + +/** + * Splits a filename so it wraps before `.` and after `-` or `_`, as in + * `MutatorSans` / `.designspace`. + */ +export function filenameWrapChunks(name: string): string[] { + return name.split(/(?<=[-_])|(?=\.)/).filter((chunk) => chunk.length > 0); +} + +/** + * Number of leading characters a middle-truncated name can keep before it + * would reach the preserved tail; keeping this many or more leaves it whole. + */ +export function middleTruncationLimit(name: string): number { + const extensionIndex = name.lastIndexOf("."); + const stemEnd = extensionIndex > 0 ? extensionIndex : name.length; + return Math.max(0, stemEnd - TAIL_STEM_CHARACTERS); +} + +/** + * Replaces the middle of a filename with an ellipsis, keeping its first + * `headLength` characters plus the end of the stem and the extension, as + * Finder does: `OpenSans-Cond…alic.shift`. + */ +export function middleTruncatedName(name: string, headLength: number): string { + const tailStart = middleTruncationLimit(name); + if (headLength >= tailStart) return name; + + return `${name.slice(0, headLength).trimEnd()}…${name.slice(tailStart)}`; +} diff --git a/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts b/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts new file mode 100644 index 000000000..f2482b2f1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import type { RecentDocument } from "@shared/recents"; +import { getShiftHost } from "@/host/shiftHost"; + +const CLOCK_TICK_MS = 60_000; + +/** + * Subscribes to main's recent files. + * + * @remarks + * Refetches when the window regains focus so files moved or deleted while the + * launcher sat in the background show as missing. + * + * @returns recent files newest first, or null until the first list arrives. + */ +export function useRecentDocuments(): RecentDocument[] | null { + const [documents, setDocuments] = useState(null); + + useEffect(() => { + const host = getShiftHost(); + let active = true; + + const refresh = async () => { + try { + const listed = await host.recents.list(); + if (active) setDocuments(listed); + } catch (error) { + console.error("listing recent files failed", error); + } + }; + + void refresh(); + const unsubscribe = host.recents.onChanged(setDocuments); + window.addEventListener("focus", refresh); + return () => { + active = false; + unsubscribe(); + window.removeEventListener("focus", refresh); + }; + }, []); + + return documents; +} + +/** Returns the current time, advancing once a minute so relative labels stay fresh. */ +export function useMinuteClock(): number { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS); + return () => clearInterval(timer); + }, []); + + return now; +} + +/** + * Tracks whether Alt (Option) is held, resetting when the window loses focus + * so a key released elsewhere cannot leave it stuck on. + */ +export function useAltKeyHeld(): boolean { + const [held, setHeld] = useState(false); + + useEffect(() => { + const update = (event: KeyboardEvent) => setHeld(event.altKey); + const release = () => setHeld(false); + window.addEventListener("keydown", update); + window.addEventListener("keyup", update); + window.addEventListener("blur", release); + return () => { + window.removeEventListener("keydown", update); + window.removeEventListener("keyup", update); + window.removeEventListener("blur", release); + }; + }, []); + + return held; +} diff --git a/apps/desktop/src/renderer/src/components/launcher/useRecentFileActions.ts b/apps/desktop/src/renderer/src/components/launcher/useRecentFileActions.ts new file mode 100644 index 000000000..29d8f6822 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/useRecentFileActions.ts @@ -0,0 +1,60 @@ +import { useMemo } from "react"; +import { useToastManager } from "@shift/ui"; +import { pathBasename, type RecentDocument } from "@shared/recents"; +import { getShiftHost } from "@/host/shiftHost"; + +/** Undo payload carried by the "removed from Recents" toast. */ +export type RemovedRecentToast = { document: RecentDocument }; + +/** What a user can do with one recent file from the launcher. */ +export type RecentFileActions = { + open: (document: RecentDocument) => Promise; + locate: (document: RecentDocument) => Promise; + reveal: (document: RecentDocument) => Promise; + copyPath: (document: RecentDocument) => Promise; + /** Removes the file from recents and offers undo in a toast. */ + remove: (document: RecentDocument) => Promise; + undoRemove: (document: RecentDocument) => Promise; +}; + +/** + * Binds recent-file actions to the Shift host and the launcher's toast manager. + * + * @remarks + * Must render inside the launcher's `ToastProvider`. Failures are logged; open + * and locate failures already raise a native message from main. + */ +export function useRecentFileActions(): RecentFileActions { + const toasts = useToastManager(); + + return useMemo(() => { + const host = getShiftHost(); + + const run = async (label: string, action: () => Promise) => { + try { + await action(); + } catch (error) { + console.error(`${label} failed`, error); + } + }; + + return { + open: (document) => run("opening recent file", () => host.recents.open(document.path)), + locate: (document) => run("locating recent file", () => host.recents.locate(document.path)), + reveal: (document) => run("revealing recent file", () => host.recents.reveal(document.path)), + copyPath: (document) => + run("copying recent file path", () => host.clipboard.writeText(document.path)), + remove: (document) => + run("removing recent file", async () => { + const removed = await host.recents.remove(document.path); + if (!removed) return; + + toasts.add({ + title: `Removed ${pathBasename(removed.path)} from Recents`, + data: { document: removed }, + }); + }), + undoRemove: (document) => run("restoring recent file", () => host.recents.restore(document)), + }; + }, [toasts]); +} diff --git a/apps/desktop/src/renderer/src/lib/themes/index.test.ts b/apps/desktop/src/renderer/src/lib/themes/index.test.ts index 98baee66e..57c77dedc 100644 --- a/apps/desktop/src/renderer/src/lib/themes/index.test.ts +++ b/apps/desktop/src/renderer/src/lib/themes/index.test.ts @@ -56,6 +56,8 @@ describe("color themes", () => { expect(properties.get("--color-line-subtle")).not.toBe(properties.get("--color-surface")); expect(properties.get("--editor-variation-outline-color")).toBe("rgba(139, 233, 253, 0.45)"); expect(properties.get("--editor-read-only-lock-color")).toBe("#f8f8f2"); + // Dracula's accent is a pale cyan, so text on it must be dark, not white. + expect(properties.get("--color-on-accent")).toBe(properties.get("--color-background")); applyResolvedTheme(resolveThemeSelection("shift-light", "dark"), root); expect(root.dataset.theme).toBe("light"); diff --git a/apps/desktop/src/renderer/src/lib/themes/index.ts b/apps/desktop/src/renderer/src/lib/themes/index.ts index 4107ebdd9..d20af88c2 100644 --- a/apps/desktop/src/renderer/src/lib/themes/index.ts +++ b/apps/desktop/src/renderer/src/lib/themes/index.ts @@ -298,11 +298,17 @@ function colorThemeVariables(theme: ColorTheme): Record { "--color-icon-button-hover": dark ? withAlpha(palette.base05, 0.2) : palette.base02, "--color-line": dark ? palette.base03 : palette.base06, "--color-line-subtle": dark ? withAlpha(palette.base05, 0.2) : palette.base01, + "--color-shadow-ambient": dark ? "rgba(0, 0, 0, 0.55)" : "rgba(50, 50, 93, 0.25)", + "--color-shadow-key": dark ? "rgba(0, 0, 0, 0.7)" : "rgba(0, 0, 0, 0.3)", "--color-primary": palette.base05, "--color-secondary": secondary, "--color-muted": muted, "--color-sidebar-icon": secondary, "--color-accent": palette.base0D, + "--color-on-accent": readableOn(palette.base0D, [ + "#ffffff", + darkest(palette.base00, palette.base07), + ]), "--color-error": palette.base08, "--color-error-ring": palette.base08, "--color-destructive": palette.base08, @@ -357,6 +363,28 @@ function colorThemeVariables(theme: ColorTheme): Record { }; } +/** Returns the candidate text colour with the highest WCAG contrast against `background`. */ +function readableOn(background: string, candidates: readonly string[]): string { + const backgroundLuminance = relativeLuminance(background); + const contrast = (color: string) => { + const [lighter, darker] = [relativeLuminance(color), backgroundLuminance].sort((a, b) => b - a); + return (lighter + 0.05) / (darker + 0.05); + }; + return candidates.reduce((best, color) => (contrast(color) > contrast(best) ? color : best)); +} + +function darkest(first: string, second: string): string { + return relativeLuminance(first) <= relativeLuminance(second) ? first : second; +} + +function relativeLuminance(color: string): number { + const channel = (offset: number) => { + const value = Number.parseInt(color.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + function withAlpha(color: string, alpha: number): string { const red = Number.parseInt(color.slice(1, 3), 16); const green = Number.parseInt(color.slice(3, 5), 16); diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index fed749631..080644906 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -1,64 +1,48 @@ -import { Button, Separator } from "@shift/ui"; -import { LauncherLogo } from "@/app/branding"; +import { useEffect } from "react"; +import { LauncherLockup } from "@/app/branding"; import { shiftProductName } from "@/app/release"; -import { RecentFiles } from "./RecentFiles"; +import { RecentFiles } from "@/components/launcher/RecentFiles"; +import { useRecentDocuments } from "@/components/launcher/useRecentDocuments"; import { Titlebar } from "@/components/chrome/Titlebar"; import { getShiftHost } from "@/host/shiftHost"; export const Landing = () => { - const host = getShiftHost(); + const recentDocuments = useRecentDocuments(); + const recentsLoaded = recentDocuments !== null; - const handleNewFont = async () => { - try { - await host.commands.run("file.new"); - } catch (error) { - console.error("new font failed", error); - } - }; + useEffect(() => { + if (!recentsLoaded) return undefined; - const handleOpenFont = async () => { - try { - await host.commands.run("file.open"); - } catch (error) { - console.error("opening a font failed", error); - } - }; + // Wait for the frame with the recent files to paint before main shows the window. + const frame = requestAnimationFrame(() => { + void signalReady(); + }); + return () => cancelAnimationFrame(frame); + }, [recentsLoaded]); return ( -
    - -
    -
    -
    +
    ); }; + +async function signalReady(): Promise { + try { + await getShiftHost().window.ready(); + } catch (error) { + console.error("signalling launcher readiness failed", error); + } +} diff --git a/apps/desktop/src/renderer/src/views/RecentFiles.tsx b/apps/desktop/src/renderer/src/views/RecentFiles.tsx deleted file mode 100644 index d808adb4a..000000000 --- a/apps/desktop/src/renderer/src/views/RecentFiles.tsx +++ /dev/null @@ -1,7 +0,0 @@ -interface RecentFilesProps { - onOpenFile: (path: string) => void; -} - -export const RecentFiles = (_props: RecentFilesProps) => { - return null; -}; diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 5f8b660b6..57001bbe9 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -2,6 +2,7 @@ import type { CommandId, RendererCommandId } from "../commands"; import type { UpdateProgress } from "../update/types"; import type { RendererErrorReport } from "../ipc/contract"; import type { FontSessionMode } from "../workspace/protocol"; +import type { RecentDocument } from "../recents"; /** * Renderer-facing API for Electron app-shell behavior. @@ -91,6 +92,14 @@ export interface ShiftHost { window: { /** Reconstructs this document renderer without clearing recovery state. */ reopenDocument: () => Promise; + /** + * Tells main this window has rendered its content and can be shown. + * + * @remarks + * Windows that main creates hidden, such as the launcher, appear on this + * signal instead of on first paint; repeated calls are ignored. + */ + ready: () => Promise; }; /** Privacy-safe renderer diagnostics reported to the main log. */ errors: { @@ -105,6 +114,36 @@ export interface ShiftHost { */ onZoomChanged: (callback: (percent: number) => void) => () => void; }; + /** Main-owned list of files Shift has opened. */ + recents: { + /** Returns recent files, newest first, with a fresh missing-file check. */ + list: () => Promise; + /** + * Opens a recent file from this window. + * + * @remarks + * Main shows a native failure message when the file cannot be opened. + */ + open: (path: string) => Promise; + /** + * Removes one file from recents. + * + * @returns the removed entry for {@link restore}, or null when it was already gone. + */ + remove: (path: string) => Promise; + /** Puts a removed entry back in its original time order. */ + restore: (document: RecentDocument) => Promise; + /** Shows the file in Finder or the platform file manager. */ + reveal: (path: string) => Promise; + /** Asks for the new location of a missing file and opens it. */ + locate: (path: string) => Promise; + /** + * Subscribes to recent-file changes. + * + * @returns an unsubscribe function. + */ + onChanged: (callback: (documents: RecentDocument[]) => void) => () => void; + }; /** System clipboard access owned by the app shell. */ clipboard: { writeText: (text: string) => Promise; diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index 899c848f2..ca1c233c1 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -5,6 +5,7 @@ import type { WorkspaceExportResult, } from "../workspace/protocol"; import type { UpdateProgress } from "../update/types"; +import type { RecentDocument } from "../recents"; export type DocumentCallMap = { "document.state": { request: void; response: WorkspaceDocumentState | null }; @@ -57,11 +58,22 @@ export type RendererToMain = { "session.connect": () => void; "session.ready": () => void; "window.reopenDocument": () => void; + /** Reports that the sender's first meaningful content is rendered, so main can show it. */ + "window.ready": () => void; "errors.reportRenderer": (report: RendererErrorReport) => void; "update.startDownload": () => void; "update.cancelDownload": () => void; "update.restartToUpdate": () => void; "update.later": () => void; + "recents.list": () => RecentDocument[]; + /** Opens a recent file from the sender window, replacing it when it is the launcher. */ + "recents.open": (path: string) => void; + /** Returns the removed entry so the renderer can offer undo. */ + "recents.remove": (path: string) => RecentDocument | null; + "recents.restore": (document: RecentDocument) => void; + "recents.reveal": (path: string) => void; + /** Asks for the new location of a missing file, opens it, and drops the stale entry. */ + "recents.locate": (path: string) => void; }; /** @@ -82,4 +94,6 @@ export type MainToRenderer = { "update.progress": (progress: UpdateProgress) => void; /** Reports that the downloaded application version can be installed. */ "update.ready": (version: string) => void; + /** Recent files changed after an open, Save As, removal, or Clear Menu. */ + "recents.changed": (documents: RecentDocument[]) => void; }; diff --git a/apps/desktop/src/shared/recents.test.ts b/apps/desktop/src/shared/recents.test.ts new file mode 100644 index 000000000..d6e71bb2c --- /dev/null +++ b/apps/desktop/src/shared/recents.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { recentFolderLabels, recentOpenedLabel } from "./recents"; + +describe("recentOpenedLabel", () => { + const now = new Date(2026, 8, 29, 15, 0).getTime(); + const at = (month: number, day: number, hour = 12, year = 2026) => + new Date(year, month, day, hour).getTime(); + + it("describes the last week relatively", () => { + expect(recentOpenedLabel(now - 20_000, now)).toBe("just now"); + expect(recentOpenedLabel(now - 12 * 60_000, now)).toBe("12 min ago"); + expect(recentOpenedLabel(at(8, 29, 14), now)).toBe("1h ago"); + expect(recentOpenedLabel(at(8, 28, 23), now)).toBe("yesterday"); + expect(recentOpenedLabel(at(8, 26), now)).toBe("3 days ago"); + }); + + it("switches to a date after a week, adding the year only when it differs", () => { + expect(recentOpenedLabel(at(8, 12), now)).toBe("12 Sep"); + expect(recentOpenedLabel(at(11, 2, 12, 2025), now)).toBe("2 Dec 2025"); + }); +}); + +describe("recentFolderLabels", () => { + it("leaves files with a unique name unlabelled", () => { + expect(recentFolderLabels(["/a/One.ufo", "/b/Two.ufo"]).size).toBe(0); + }); + + it("names the nearest parent folder that tells same-named files apart", () => { + const labels = recentFolderLabels([ + "/work/Roman/sources/Font.ufo", + "/work/Italic/sources/Font.ufo", + "/work/Other.glyphs", + ]); + + expect(Object.fromEntries(labels)).toEqual({ + "/work/Roman/sources/Font.ufo": "Roman", + "/work/Italic/sources/Font.ufo": "Italic", + }); + }); + + it("falls back to the parent path when no single folder distinguishes them", () => { + const labels = recentFolderLabels(["/a/x/Font.ufo", "/a/y/Font.ufo", "/b/x/Font.ufo"]); + + expect(new Set(labels.values()).size).toBe(3); + }); +}); diff --git a/apps/desktop/src/shared/recents.ts b/apps/desktop/src/shared/recents.ts new file mode 100644 index 000000000..728c17a41 --- /dev/null +++ b/apps/desktop/src/shared/recents.ts @@ -0,0 +1,109 @@ +import type { Specimen } from "./workspace/protocol"; + +/** One file Shift has opened, as the launcher and File → Open Recent show it. */ +export type RecentDocument = { + /** Canonical path of the file when it was last opened. */ + path: string; + /** Native `.shift` identity; null for source and binary fonts. */ + documentId: string | null; + /** Epoch milliseconds of the most recent open. */ + openedAt: number; + /** Containing folder for display, with the home directory shortened to `~`. */ + location: string; + /** True when nothing exists at `path` any more. */ + missing: boolean; + /** + * Cached thumbnail specimen; null when none was built yet, the font draws + * nothing, or the file changed outside Shift since it was built. + */ + specimen: Specimen | null; +}; + +/** A file open that should be recorded in recents. */ +export type RecentDocumentVisit = { + path: string; + documentId: string | null; +}; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const RELATIVE_DAY_LIMIT = 7; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** Returns the final path segment, accepting both separators. */ +export function pathBasename(filePath: string): string { + const segments = pathSegments(filePath); + return segments[segments.length - 1] ?? filePath; +} + +/** + * Formats when a file was last opened: relative for the past week, then a date. + * + * @param openedAt - Epoch milliseconds of the open. + * @param now - Current epoch milliseconds. + */ +export function recentOpenedLabel(openedAt: number, now: number): string { + const elapsed = Math.max(0, now - openedAt); + if (elapsed < MINUTE_MS) return "just now"; + if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)} min ago`; + + const days = calendarDaysBetween(openedAt, now); + if (days === 0) return `${Math.floor(elapsed / HOUR_MS)}h ago`; + if (days === 1) return "yesterday"; + if (days < RELATIVE_DAY_LIMIT) return `${days} days ago`; + + const opened = new Date(openedAt); + const date = `${opened.getDate()} ${MONTHS[opened.getMonth()]}`; + const sameYear = opened.getFullYear() === new Date(now).getFullYear(); + return sameYear ? date : `${date} ${opened.getFullYear()}`; +} + +/** + * Finds the parent folder that tells apart recent files sharing a filename. + * + * @param paths - Paths of every listed recent file. + * @returns folder label per path; paths with a unique filename are absent. + */ +export function recentFolderLabels(paths: readonly string[]): Map { + const pathsByName = new Map(); + for (const filePath of paths) { + const name = pathBasename(filePath); + pathsByName.set(name, [...(pathsByName.get(name) ?? []), filePath]); + } + + const labels = new Map(); + for (const group of pathsByName.values()) { + if (group.length < 2) continue; + + for (const [filePath, label] of distinguishingFolders(group)) labels.set(filePath, label); + } + return labels; +} + +function distinguishingFolders(group: readonly string[]): Map { + const parents = group.map((filePath) => pathSegments(filePath).slice(0, -1).reverse()); + const depth = Math.max(...parents.map((segments) => segments.length)); + + for (let level = 0; level < depth; level++) { + const folders = parents.map((segments) => segments[level] ?? ""); + if (new Set(folders).size === group.length) { + return new Map(group.map((filePath, index) => [filePath, folders[index]])); + } + } + + return new Map( + group.map((filePath, index) => [filePath, [...parents[index]].reverse().join("/")]), + ); +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]/).filter((segment) => segment.length > 0); +} + +function calendarDaysBetween(from: number, to: number): number { + const start = new Date(from); + const end = new Date(to); + const startDay = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate()); + const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()); + return Math.round((endDay - startDay) / (24 * HOUR_MS)); +} diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index e223df8a3..225644137 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -12,12 +12,14 @@ import type { LayerMatch, Location, SlugAtlas, + Specimen, WorkspaceDocumentState, WorkspaceSnapshot, } from "@shift/types"; export type { FontSessionMode, + Specimen, WorkspaceDocumentSourceKind, WorkspaceDocumentState, WorkspaceGlyphLayerSnapshot, @@ -156,6 +158,11 @@ export type ShellCallMap = { "source.close": { request: void; response: null }; "workspace.connect": { request: void; response: void }; "document.state": { request: void; response: WorkspaceDocumentState | null }; + /** + * Builds the recent-files thumbnail specimen: from the open document's + * current state, otherwise from the font file at `path`. + */ + "workspace.specimen": { request: { path: string }; response: Specimen | null }; }; export type ShellEventMap = { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index f281a1789..cc1c7bf1a 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -1,4 +1,4 @@ -import { createBridge, type ShiftBridge } from "@shift/bridge"; +import { createBridge, type ShiftBridge, type Specimen } from "@shift/bridge"; import type { GlyphSnapshot } from "@shift/types"; import fs from "node:fs"; import path from "node:path"; @@ -121,6 +121,7 @@ export class WorkspaceHost { this.#connectSyncLane(context.ports); }, "document.state": () => this.#serialize(() => this.#documentState()), + "workspace.specimen": ({ path }) => this.#specimen(path), }); this.#shell.emit("ready", undefined); @@ -764,6 +765,18 @@ export class WorkspaceHost { return { path: result.path, format: "ttf" }; } + async #specimen(path: string): Promise { + const { completion } = await this.#serialize(() => ({ + // Both calls capture their input synchronously and compile on a worker + // thread, so the workspace queue is released while fontc runs. + completion: + this.#state.kind === "document" + ? this.#bridge.documentSpecimen() + : this.#bridge.fontFileSpecimen(path), + })); + return completion; + } + async #close(discard: boolean): Promise { const state = this.#documentState(); if (!state) return null; diff --git a/crates/shift-backends/src/export.rs b/crates/shift-backends/src/export.rs index 153bc21b8..5ba181e69 100644 --- a/crates/shift-backends/src/export.rs +++ b/crates/shift-backends/src/export.rs @@ -120,9 +120,15 @@ impl FontExporter { }) } - fn export_ttf(&self, font: &impl FontView, output_path: &Path) -> Result<(), ExportError> { - ensure_ttf_output_path(output_path)?; - + /// Compiles a TrueType binary from the supplied font without writing it + /// anywhere; build intermediates live in a temporary directory that is + /// removed before returning. + /// + /// # Errors + /// + /// Returns [`ExportError`] when the source cannot be represented in the + /// supported compiler model or compilation fails. + pub fn compile_ttf(&self, font: &impl FontView) -> Result, ExportError> { let temp_dir = tempfile::Builder::new() .prefix("shift-export-") .tempdir() @@ -130,7 +136,13 @@ impl FontExporter { let build_dir = temp_dir.path().join("build"); let source = ShiftIrSource::from_font_view(font).map_err(map_source_error)?; - let bytes = compile_ttf(source, &build_dir)?; + compile_ttf(source, &build_dir) + } + + fn export_ttf(&self, font: &impl FontView, output_path: &Path) -> Result<(), ExportError> { + ensure_ttf_output_path(output_path)?; + + let bytes = self.compile_ttf(font)?; write_file_atomic(output_path, &bytes).map_err(|source| ExportError::WriteOutput { path: output_path.to_path_buf(), source, diff --git a/crates/shift-backends/src/glyph_subset.rs b/crates/shift-backends/src/glyph_subset.rs new file mode 100644 index 000000000..04720628a --- /dev/null +++ b/crates/shift-backends/src/glyph_subset.rs @@ -0,0 +1,131 @@ +//! A read-only view of a font restricted to a few glyphs, for compiling just +//! what a preview needs. + +use std::collections::HashSet; + +use shift_font::{ + Axis, AxisMapping, BinaryData, FeatureData, FontMetadata, FontMetrics, Glyph, Guideline, + KerningData, LibData, MetricDefinition, NamedInstance, Source, SourceId, +}; + +use crate::traits::FontView; + +/// Exposes only the kept glyphs of `font`, plus every glyph they use as a +/// component, with no feature code or kerning. +/// +/// Glyph order follows the underlying font. Dropping features and kerning +/// keeps the compile independent of glyphs outside the subset; text that +/// needs contextual forms must be compiled from the whole font instead. +pub struct GlyphSubsetView<'a, F: FontView> { + font: &'a F, + kept: HashSet, + features: FeatureData, + kerning: KerningData, +} + +impl<'a, F: FontView> GlyphSubsetView<'a, F> { + /// Keeps the named glyphs and their component closure; unknown names are ignored. + pub fn new<'n>(font: &'a F, names: impl IntoIterator) -> Self { + let mut kept = HashSet::new(); + let mut pending: Vec = names.into_iter().map(str::to_owned).collect(); + while let Some(name) = pending.pop() { + let Some(glyph) = font.glyph(&name) else { + continue; + }; + if !kept.insert(name) { + continue; + } + for layer in glyph.layers().values() { + pending.extend( + layer + .components_iter() + .map(|component| component.base_glyph_name().to_string()), + ); + } + } + + Self { + font, + kept, + features: FeatureData::default(), + kerning: KerningData::default(), + } + } +} + +impl FontView for GlyphSubsetView<'_, F> { + fn metadata(&self) -> &FontMetadata { + self.font.metadata() + } + + fn metrics(&self) -> &FontMetrics { + self.font.metrics() + } + + fn metric_definitions(&self) -> &[MetricDefinition] { + self.font.metric_definitions() + } + + fn axes(&self) -> &[Axis] { + self.font.axes() + } + + fn axis_mappings(&self) -> &[AxisMapping] { + self.font.axis_mappings() + } + + fn named_instances(&self) -> &[NamedInstance] { + self.font.named_instances() + } + + fn sources(&self) -> &[Source] { + self.font.sources() + } + + fn default_source_id(&self) -> Option { + self.font.default_source_id() + } + + fn glyphs(&self) -> Vec<&Glyph> { + self.font + .glyphs() + .into_iter() + .filter(|glyph| self.kept.contains(glyph.name())) + .collect() + } + + fn glyph(&self, name: &str) -> Option<&Glyph> { + self.kept + .contains(name) + .then(|| self.font.glyph(name)) + .flatten() + } + + fn kerning(&self) -> &KerningData { + &self.kerning + } + + fn features(&self) -> &FeatureData { + &self.features + } + + fn guidelines(&self) -> &[Guideline] { + self.font.guidelines() + } + + fn lib(&self) -> &LibData { + self.font.lib() + } + + fn fontinfo_remainder(&self) -> &LibData { + self.font.fontinfo_remainder() + } + + fn data_files(&self) -> &BinaryData { + self.font.data_files() + } + + fn images(&self) -> &BinaryData { + self.font.images() + } +} diff --git a/crates/shift-backends/src/lib.rs b/crates/shift-backends/src/lib.rs index 0c5c0d9b2..ecf8ffba3 100644 --- a/crates/shift-backends/src/lib.rs +++ b/crates/shift-backends/src/lib.rs @@ -5,6 +5,7 @@ pub mod font_loader; pub mod font_source; pub mod format; pub mod formats; +mod glyph_subset; pub mod import; mod import_report; mod metrics; @@ -25,6 +26,7 @@ pub use font_source::{ VariationRegion, VariationSupport, }; pub use format::FontFormat; +pub use glyph_subset::GlyphSubsetView; pub use import::{FontImport, GlyphDirectoryEntry, ImportBatchLimit}; pub use import_report::{ImportLoss, ImportLossKind, ImportReport}; pub use traits::{FontBackend, FontReader, FontView, FontWriter}; diff --git a/crates/shift-bridge/Cargo.toml b/crates/shift-bridge/Cargo.toml index 25d9ff409..760666e03 100644 --- a/crates/shift-bridge/Cargo.toml +++ b/crates/shift-bridge/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["cdylib"] [dependencies] shift-font = { workspace = true } shift-slug = { workspace = true } +shift-specimen = { workspace = true } shift-wire = { workspace = true, features = ["napi"] } shift-backends = { workspace = true } shift-workspace = { workspace = true } diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index e82a580a6..552857962 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -19,6 +19,21 @@ export declare class Bridge { constructor() createUntitledWorkspace(storePath: string, options?: NapiNewWorkspace | undefined | null): void exportWorkspace(request: NapiFontExportRequest): Promise + /** + * Builds the thumbnail specimen for a font file, independent of any + * document or preview this bridge holds. + * + * Resolves to `null` when the font draws nothing usable; rejects when the + * file cannot be read or compiled. + */ + fontFileSpecimen(path: string): Promise + /** + * Builds the thumbnail specimen from the open document's current state. + * + * Resolves to `null` when the font draws nothing usable; rejects when no + * workspace is open or compilation fails. + */ + documentSpecimen(): Promise documentState(): NapiDocumentState inspectDocument(path: string): NapiDocumentIdentity closeWorkspace(): void @@ -169,6 +184,20 @@ export interface NapiNewWorkspace { familyName?: string unitsPerEm?: number } + +/** + * Specimen outline drawn on a recent file's thumbnail, in font units with y + * pointing down. + */ +export interface NapiSpecimen { + /** Characters shown; empty when glyphs were chosen by glyph order. */ + text: string + /** SVG path data. */ + outline: string + /** SVG `viewBox` as `[x, y, width, height]`, fitted to the ink. */ + viewBox: Array + rightToLeft: boolean +} export interface NapiAddAnchorsIntent { layerId: LayerId anchors: Array diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index f3034feef..4b4186a4f 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -1173,6 +1173,33 @@ impl Bridge { })) } + /// Builds the thumbnail specimen for a font file, independent of any + /// document or preview this bridge holds. + /// + /// Resolves to `null` when the font draws nothing usable; rejects when the + /// file cannot be read or compiled. + #[napi(ts_return_type = "Promise")] + pub fn font_file_specimen(&self, path: String) -> AsyncTask { + AsyncTask::new(crate::specimen::SpecimenTask { + input: crate::specimen::SpecimenInput::Path(std::path::PathBuf::from(path)), + }) + } + + /// Builds the thumbnail specimen from the open document's current state. + /// + /// Resolves to `null` when the font draws nothing usable; rejects when no + /// workspace is open or compilation fails. + #[napi(ts_return_type = "Promise")] + pub fn document_specimen(&mut self) -> Result> { + Ok(AsyncTask::new(crate::specimen::SpecimenTask { + input: crate::specimen::SpecimenInput::Snapshot( + self + .save_snapshot() + .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?, + ), + })) + } + #[napi] pub fn document_state(&self) -> errors::Result { self.document_state_snapshot() diff --git a/crates/shift-bridge/src/lib.rs b/crates/shift-bridge/src/lib.rs index 56e194751..ba5ef8310 100644 --- a/crates/shift-bridge/src/lib.rs +++ b/crates/shift-bridge/src/lib.rs @@ -1,3 +1,4 @@ mod bridge; mod errors; mod input; +mod specimen; diff --git a/crates/shift-bridge/src/specimen.rs b/crates/shift-bridge/src/specimen.rs new file mode 100644 index 000000000..1eeaab89d --- /dev/null +++ b/crates/shift-bridge/src/specimen.rs @@ -0,0 +1,150 @@ +use std::path::PathBuf; + +use napi::bindgen_prelude::*; +use napi::{Error, Status}; +use napi_derive::napi; +use shift_backends::{font_loader::FontLoader, FontExporter, FontView, GlyphSubsetView}; +use shift_font::Glyph; + +use crate::bridge::FontSaveSnapshot; + +/// Specimen outline drawn on a recent file's thumbnail, in font units with y +/// pointing down. +#[napi(object)] +pub struct NapiSpecimen { + /// Characters shown; empty when glyphs were chosen by glyph order. + pub text: String, + /// SVG path data. + pub outline: String, + /// SVG `viewBox` as `[x, y, width, height]`, fitted to the ink. + pub view_box: Vec, + pub right_to_left: bool, +} + +impl From for NapiSpecimen { + fn from(specimen: shift_specimen::Specimen) -> Self { + let view_box = specimen.view_box; + Self { + text: specimen.text, + outline: specimen.outline, + view_box: vec![view_box.x, view_box.y, view_box.width, view_box.height], + right_to_left: specimen.right_to_left, + } + } +} + +/// Font whose specimen is built on a worker thread. +pub(crate) enum SpecimenInput { + /// Live authored document, compiled from a snapshot of its current state. + Snapshot(FontSaveSnapshot), + /// Font file on disk: binaries are shaped as they are, sources are + /// imported and compiled first. + Path(PathBuf), +} + +/// Compiles what the specimen needs, then chooses and outlines it. +pub struct SpecimenTask { + pub(crate) input: SpecimenInput, +} + +impl SpecimenTask { + fn build(&self) -> std::result::Result, String> { + match &self.input { + SpecimenInput::Snapshot(snapshot) => source_specimen(snapshot), + SpecimenInput::Path(path) if is_font_binary(path) => { + let binary = std::fs::read(path).map_err(|error| error.to_string())?; + Ok(shift_specimen::specimen(&binary)) + } + SpecimenInput::Path(path) => { + let font = FontLoader::new() + .read_font(&path.to_string_lossy()) + .map_err(|error| error.to_string())?; + source_specimen(&font) + } + } + } +} + +/// Glyphs with outlines kept from the start of glyph order, for the +/// specimen's "first drawn glyphs" fallback. +const LEADING_DRAWN_GLYPHS: usize = 2; + +/// Builds a source font's specimen by compiling only the glyphs it can use. +/// +/// The specimen rules decide from the whole font's character map; only the +/// candidate glyphs, `.notdef`, and the first drawn glyphs are compiled. A +/// font whose specimen needs contextual forms is compiled whole instead. +fn source_specimen( + font: &impl FontView, +) -> std::result::Result, String> { + let glyphs = font.glyphs(); + let characters: Vec = glyphs + .iter() + .flat_map(|glyph| { + glyph + .unicodes() + .iter() + .filter_map(|&code| char::from_u32(code)) + }) + .collect(); + let plan = shift_specimen::subset_plan(&characters); + + let exporter = FontExporter::new(); + let binary = if plan.needs_features { + exporter.compile_ttf(font) + } else { + let planned = glyphs.iter().filter(|glyph| { + glyph + .unicodes() + .iter() + .filter_map(|&code| char::from_u32(code)) + .any(|character| plan.characters.contains(&character)) + }); + let leading = glyphs + .iter() + .filter(|glyph| glyph.name() != ".notdef" && has_outline(glyph)) + .take(LEADING_DRAWN_GLYPHS); + let names = planned + .chain(leading) + .map(|glyph| glyph.name()) + .chain([".notdef"]); + exporter.compile_ttf(&GlyphSubsetView::new(font, names)) + } + .map_err(|error| error.to_string())?; + + Ok(shift_specimen::specimen_with_characters( + &binary, + &characters, + )) +} + +fn has_outline(glyph: &Glyph) -> bool { + glyph + .layers() + .values() + .any(|layer| !layer.contours().is_empty() || !layer.components().is_empty()) +} + +impl Task for SpecimenTask { + type Output = Option; + type JsValue = Option; + + fn compute(&mut self) -> Result { + self + .build() + .map_err(|message| Error::new(Status::GenericFailure, message)) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output.map(NapiSpecimen::from)) + } +} + +fn is_font_binary(path: &std::path::Path) -> bool { + path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("ttf") || extension.eq_ignore_ascii_case("otf") + }) +} diff --git a/crates/shift-specimen/Cargo.toml b/crates/shift-specimen/Cargo.toml new file mode 100644 index 000000000..fa477f282 --- /dev/null +++ b/crates/shift-specimen/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shift-specimen" +version = "0.1.0" +edition = "2021" +description = "Chooses, shapes, and outlines the specimen text shown on Shift's recent-file thumbnails" +license.workspace = true + +[lib] +crate-type = ["rlib"] + +[dependencies] +harfrust = "0.13.3" +skrifa = "0.32.0" +unicode-script = "0.5" diff --git a/crates/shift-specimen/src/lib.rs b/crates/shift-specimen/src/lib.rs new file mode 100644 index 000000000..23331c537 --- /dev/null +++ b/crates/shift-specimen/src/lib.rs @@ -0,0 +1,644 @@ +//! Chooses, shapes, and outlines the short specimen shown on a recent file's +//! thumbnail. +//! +//! The specimen is picked from the compiled font alone, never from its sample +//! text (`name` ID 19): a symbol font shows its first drawn glyphs, a font +//! whose design languages (`meta` `dlng`) are all non-Latin shows a pair from +//! that script, any other font that draws `Ag` shows `Ag`, a font +//! covering another script shows a pair from that script, and a font with one +//! Latin case shows `AG` or `ag`. +//! Text is shaped with HarfRust at the font's +//! default location, and a candidate only counts as covered when shaping +//! draws ink for every cluster, so a mapped but empty glyph that the font +//! decomposes through GSUB still qualifies. +//! +//! Outlines are returned in font units with the y axis pointing down, ready to +//! use as SVG path data. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use harfrust::{ShapeOptions, ShaperData, UnicodeBuffer}; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::types::Tag; +use skrifa::raw::TableProvider; +use skrifa::{FontRef, GlyphId, MetadataProvider}; +use unicode_script::{Script, UnicodeScript}; + +/// Letters of a non-Latin script a font must cover before that script is +/// treated as its main script. +const MAIN_SCRIPT_LETTERS: usize = 20; +/// Smallest viewport height, in ems, so short marks are not blown up to fill +/// the thumbnail. +const MIN_VIEW_HEIGHT_EM: f64 = 0.6; +/// Space kept around the ink, in ems. +const PADDING_EM: f64 = 0.04; + +/// Specimen text and its outline, fitted to the ink. +#[derive(Clone, Debug, PartialEq)] +pub struct Specimen { + /// Characters shaped for the specimen; empty when glyphs were placed by + /// glyph order because no characters describe them. + pub text: String, + /// SVG path data in font units, y down. + pub outline: String, + /// Viewport around the ink in the same units as [`Self::outline`], + /// padded and enlarged to at least a minimum height. + pub view_box: ViewBox, + /// Whether the text runs right to left; a thumbnail wider than the + /// specimen aligns it to the right edge. + pub right_to_left: bool, +} + +/// Rectangle in SVG viewport coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewBox { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// Builds the thumbnail specimen for a compiled TrueType or OpenType font. +/// +/// Returns `None` when the bytes are not a readable font or when no rule +/// finds glyphs with ink; callers show an empty thumbnail in that case. +pub fn specimen(font_data: &[u8]) -> Option { + let font = SpecimenFont::new(font_data)?; + let coverage = Coverage::from_characters(font.characters()); + specimen_from(&font, &coverage) +} + +/// Builds the specimen from a compiled subset of a font, deciding with the +/// full font's character map. +/// +/// `font_data` must include the glyphs for every character in +/// [`subset_plan`]'s result, plus the first glyphs with outlines in glyph +/// order; `characters` is every character the whole font maps. +pub fn specimen_with_characters(font_data: &[u8], characters: &[char]) -> Option { + let font = SpecimenFont::new(font_data)?; + let coverage = Coverage::from_characters(characters.iter().copied()); + specimen_from(&font, &coverage) +} + +/// What a font source must compile so [`specimen_with_characters`] can draw +/// its specimen without compiling every glyph. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubsetPlan { + /// Characters any specimen rule might shape; compile their glyphs. + pub characters: Vec, + /// Whether the chosen script needs contextual shaping features, so the + /// whole font must be compiled with its feature code. + pub needs_features: bool, +} + +/// Plans the smallest compile that still draws the right specimen. +/// +/// Only the glyph-independent rules are applied here: the specimen text is +/// still chosen after shaping the compiled subset. +pub fn subset_plan(characters: &[char]) -> SubsetPlan { + let coverage = Coverage::from_characters(characters.iter().copied()); + if coverage.is_symbol_font() { + return SubsetPlan { + characters: Vec::new(), + needs_features: false, + }; + } + + let covered = |character: &char| characters.contains(character); + let mut planned: Vec = ['A', 'g', 'G', 'a'].into_iter().filter(covered).collect(); + let draws_ag = covered(&'A') && covered(&'g'); + let script = coverage.main_script(); + if let Some(script) = script { + planned.extend(script.pair().chars().filter(covered)); + planned.extend( + coverage + .letters(script) + .into_iter() + .take(SCRIPT_FALLBACK_LETTERS), + ); + } + planned.sort_unstable(); + planned.dedup(); + + SubsetPlan { + characters: planned, + needs_features: !draws_ag && script.is_some_and(SpecimenScript::needs_contextual_forms), + } +} + +fn specimen_from(font: &SpecimenFont, coverage: &Coverage) -> Option { + if coverage.is_symbol_font() { + return font.glyph_order_specimen(); + } + if let Some(script) = font.declared_script() { + if let Some(specimen) = font.script_specimen(script, coverage) { + return Some(specimen); + } + } + if let Some(specimen) = font.shaped_specimen("Ag") { + return Some(specimen); + } + if let Some(script) = coverage.main_script() { + if let Some(specimen) = font.script_specimen(script, coverage) { + return Some(specimen); + } + } + + ["AG", "ag"] + .into_iter() + .find_map(|text| font.shaped_specimen(text)) + .or_else(|| font.glyph_order_specimen()) +} + +/// Letters of the main script kept in a subset for the "first two drawn +/// letters" fallback when the script's pair is not drawn. +const SCRIPT_FALLBACK_LETTERS: usize = 8; + +/// A script whose fonts get a script-specific specimen pair. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SpecimenScript { + Arabic, + Hebrew, + Devanagari, + Japanese, + Chinese, + Korean, + Greek, + Cyrillic, + Thai, +} + +impl SpecimenScript { + /// Checked in this order; the first script the font covers wins. + const ALL: [SpecimenScript; 9] = [ + Self::Arabic, + Self::Hebrew, + Self::Devanagari, + Self::Japanese, + Self::Chinese, + Self::Korean, + Self::Greek, + Self::Cyrillic, + Self::Thai, + ]; + + fn pair(self) -> &'static str { + match self { + Self::Arabic => "حب", + Self::Hebrew => "אב", + Self::Devanagari => "अक", + Self::Japanese => "永あ", + Self::Chinese => "永", + Self::Korean => "한", + Self::Greek => "Αγ", + Self::Cyrillic => "Жя", + Self::Thai => "กข", + } + } + + /// Resolves the script a `dlng` ScriptLangTag is written in, from its + /// script subtag (`fa-Arab`, `Hant`) or else its language (`ko`, `th`). + /// + /// Returns [`DeclaredScript::Other`] for Latin, and for languages and + /// scripts without a specimen pair. + fn from_language_tag(tag: &str) -> DeclaredScript { + let subtags: Vec = tag.split('-').map(str::to_ascii_lowercase).collect(); + let script_subtag = subtags + .iter() + .find(|subtag| subtag.len() == 4 && subtag.chars().all(|c| c.is_ascii_alphabetic())); + let script = match script_subtag { + Some(script) => Self::from_script_subtag(script), + None => subtags + .first() + .and_then(|language| Self::from_language(language)), + }; + script.map_or(DeclaredScript::Other, DeclaredScript::Specimen) + } + + fn from_script_subtag(subtag: &str) -> Option { + match subtag { + "arab" => Some(Self::Arabic), + "hebr" => Some(Self::Hebrew), + "deva" => Some(Self::Devanagari), + "jpan" | "hira" | "kana" | "hrkt" => Some(Self::Japanese), + "hani" | "hans" | "hant" => Some(Self::Chinese), + "kore" | "hang" => Some(Self::Korean), + "grek" => Some(Self::Greek), + "cyrl" => Some(Self::Cyrillic), + "thai" => Some(Self::Thai), + _ => None, + } + } + + fn from_language(language: &str) -> Option { + match language { + "ar" | "fa" | "ur" | "ps" | "ug" | "ckb" | "sd" => Some(Self::Arabic), + "he" | "yi" => Some(Self::Hebrew), + "hi" | "mr" | "ne" | "sa" | "mai" | "bho" => Some(Self::Devanagari), + "ja" => Some(Self::Japanese), + "zh" | "yue" => Some(Self::Chinese), + "ko" => Some(Self::Korean), + "el" => Some(Self::Greek), + "ru" | "uk" | "be" | "bg" | "mk" | "sr" | "kk" | "ky" | "tg" | "mn" => { + Some(Self::Cyrillic) + } + "th" => Some(Self::Thai), + _ => None, + } + } + + /// Whether the pair only renders correctly with GSUB contextual forms. + fn needs_contextual_forms(self) -> bool { + self == Self::Arabic + } + + fn includes(self, script: Script) -> bool { + match self { + Self::Arabic => script == Script::Arabic, + Self::Hebrew => script == Script::Hebrew, + Self::Devanagari => script == Script::Devanagari, + Self::Japanese => matches!(script, Script::Hiragana | Script::Katakana), + Self::Chinese => script == Script::Han, + Self::Korean => script == Script::Hangul, + Self::Greek => script == Script::Greek, + Self::Cyrillic => script == Script::Cyrillic, + Self::Thai => script == Script::Thai, + } + } +} + +/// Script of one declared design language. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DeclaredScript { + Specimen(SpecimenScript), + /// Latin, or a script without a specimen pair. + Other, +} + +/// Character coverage read from the font's character map. +struct Coverage { + characters: usize, + symbols: usize, + letters_by_script: HashMap>, +} + +impl Coverage { + fn from_characters(characters: impl IntoIterator) -> Self { + let mut coverage = Coverage { + characters: 0, + symbols: 0, + letters_by_script: HashMap::new(), + }; + + for character in characters { + if character.is_whitespace() || character.is_control() { + continue; + } + coverage.characters += 1; + + if !character.is_alphabetic() { + if !character.is_numeric() { + coverage.symbols += 1; + } + continue; + } + + let script = character.script(); + coverage + .letters_by_script + .entry(script) + .or_default() + .push(character); + } + + for letters in coverage.letters_by_script.values_mut() { + letters.sort_unstable(); + } + coverage + } + + fn is_symbol_font(&self) -> bool { + self.characters > 0 && self.symbols * 2 > self.characters + } + + fn main_script(&self) -> Option { + SpecimenScript::ALL + .into_iter() + .find(|script| self.letters(*script).len() >= MAIN_SCRIPT_LETTERS) + } + + fn letters(&self, script: SpecimenScript) -> Vec { + let mut letters: Vec = self + .letters_by_script + .iter() + .filter(|(candidate, _)| script.includes(**candidate)) + .flat_map(|(_, letters)| letters.iter().copied()) + .collect(); + letters.sort_unstable(); + letters + } +} + +/// One glyph placed on the specimen line, in font units. +struct PlacedGlyph { + glyph_id: u32, + x: f64, + y: f64, +} + +struct SpecimenFont<'a> { + data: &'a [u8], + font: FontRef<'a>, + units_per_em: f64, +} + +impl<'a> SpecimenFont<'a> { + fn new(data: &'a [u8]) -> Option { + let font = FontRef::new(data).ok()?; + let units_per_em = f64::from(font.head().ok()?.units_per_em()); + Some(Self { + data, + font, + units_per_em, + }) + } + + fn characters(&self) -> impl Iterator + '_ { + self.font + .charmap() + .mappings() + .filter_map(|(codepoint, _)| char::from_u32(codepoint)) + } + + /// Returns the script the font declares it was designed for, but only + /// when every design language is written in a script with a specimen + /// pair. Declared languages are unordered, so a font that lists any Latin + /// language is treated as Latin-first. + fn declared_script(&self) -> Option { + let meta = self.font.meta().ok()?; + let record = meta + .data_maps() + .iter() + .find(|record| record.tag() == Tag::new(b"dlng"))?; + // read-fonts parses `dlng` into `Metadata::ScriptLangTags`, but its + // `ScriptLangTag::read_len_at` finds the next comma from the start of + // the whole list rather than the current tag, so every tag after the + // first is mis-sized (still true in read-fonts 0.43). Split the raw + // bytes here instead. + let start = record.data_offset().to_u32() as usize; + let end = start + record.data_length() as usize; + let bytes = meta.offset_data().as_bytes().get(start..end)?; + let declared: Vec = std::str::from_utf8(bytes) + .ok()? + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(SpecimenScript::from_language_tag) + .collect(); + let mut scripts = declared.iter().map(|script| match script { + DeclaredScript::Specimen(script) => Some(*script), + DeclaredScript::Other => None, + }); + let first = scripts.next()??; + scripts.all(|script| script.is_some()).then_some(first) + } + + fn script_specimen(&self, script: SpecimenScript, coverage: &Coverage) -> Option { + if let Some(specimen) = self.shaped_specimen(script.pair()) { + return Some(specimen); + } + + let drawn: String = coverage + .letters(script) + .into_iter() + .filter(|letter| self.shaped_specimen(&letter.to_string()).is_some()) + .take(2) + .collect(); + self.shaped_specimen(&drawn) + } + + /// Shapes `text` and returns it only when every cluster draws ink. + fn shaped_specimen(&self, text: &str) -> Option { + if text.is_empty() { + return None; + } + + let font = harfrust::FontRef::new(self.data).ok()?; + let data = ShaperData::new(&font); + let shaper = data.shaper(&font).build(); + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.guess_segment_properties(); + let shaped = shaper.shape(buffer, ShapeOptions::new()); + + let mut placed = Vec::new(); + let mut inked_clusters = Vec::new(); + let mut pen_x = 0.0; + for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + if info.glyph_id == 0 { + return None; + } + if self.has_ink(info.glyph_id) { + inked_clusters.push(info.cluster); + } + placed.push(PlacedGlyph { + glyph_id: info.glyph_id, + x: pen_x + f64::from(position.x_offset), + y: f64::from(position.y_offset), + }); + pen_x += f64::from(position.x_advance); + } + + let every_cluster_inked = shaped + .glyph_infos() + .iter() + .all(|info| inked_clusters.contains(&info.cluster)); + if !every_cluster_inked { + return None; + } + + let right_to_left = text + .chars() + .any(|character| matches!(character.script(), Script::Arabic | Script::Hebrew)); + self.outline(text.to_string(), &placed, right_to_left) + } + + /// Places the first two glyphs with ink by glyph order, skipping `.notdef`. + fn glyph_order_specimen(&self) -> Option { + let glyph_count = u32::from(self.font.maxp().ok()?.num_glyphs()); + let metrics = self + .font + .glyph_metrics(Size::unscaled(), LocationRef::default()); + + let mut placed = Vec::new(); + let mut pen_x = 0.0; + for glyph_id in (1..glyph_count) + .filter(|glyph_id| self.has_ink(*glyph_id)) + .take(2) + { + placed.push(PlacedGlyph { + glyph_id, + x: pen_x, + y: 0.0, + }); + pen_x += f64::from( + metrics + .advance_width(GlyphId::new(glyph_id)) + .unwrap_or_default(), + ); + } + self.outline(String::new(), &placed, false) + } + + fn has_ink(&self, glyph_id: u32) -> bool { + let mut pen = SvgPen::new(0.0, 0.0); + self.draw(glyph_id, &mut pen); + pen.bounds.is_some() + } + + fn draw(&self, glyph_id: u32, pen: &mut SvgPen) { + let outlines = self.font.outline_glyphs(); + let Some(glyph) = outlines.get(GlyphId::new(glyph_id)) else { + return; + }; + let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); + // A glyph that fails to draw simply contributes no ink. + let _ = glyph.draw(settings, pen); + } + + fn outline( + &self, + text: String, + placed: &[PlacedGlyph], + right_to_left: bool, + ) -> Option { + let mut outline = String::new(); + let mut bounds: Option = None; + for glyph in placed { + let mut pen = SvgPen::new(glyph.x, glyph.y); + self.draw(glyph.glyph_id, &mut pen); + outline.push_str(&pen.path); + bounds = match (bounds, pen.bounds) { + (Some(current), Some(next)) => Some(current.union(next)), + (current, next) => current.or(next), + }; + } + + let bounds = bounds?; + Some(Specimen { + text, + outline, + view_box: self.fit(bounds), + right_to_left, + }) + } + + /// Pads the ink and grows the viewport to the minimum height, centred on + /// the ink, which caps how far short marks are magnified. + fn fit(&self, ink: Bounds) -> ViewBox { + let padding = self.units_per_em * PADDING_EM; + let ink_height = ink.max_y - ink.min_y; + let height = (ink_height + 2.0 * padding).max(self.units_per_em * MIN_VIEW_HEIGHT_EM); + + ViewBox { + x: ink.min_x - padding, + y: ink.min_y - (height - ink_height) / 2.0, + width: ink.max_x - ink.min_x + 2.0 * padding, + height, + } + } +} + +#[derive(Clone, Copy, Debug)] +struct Bounds { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, +} + +impl Bounds { + fn point(x: f64, y: f64) -> Self { + Self { + min_x: x, + min_y: y, + max_x: x, + max_y: y, + } + } + + fn union(self, other: Bounds) -> Self { + Self { + min_x: self.min_x.min(other.min_x), + min_y: self.min_y.min(other.min_y), + max_x: self.max_x.max(other.max_x), + max_y: self.max_y.max(other.max_y), + } + } +} + +/// Collects outline commands as SVG path data, offset by the glyph's origin +/// and flipped to y down. +struct SvgPen { + origin_x: f64, + origin_y: f64, + path: String, + bounds: Option, +} + +impl SvgPen { + fn new(origin_x: f64, origin_y: f64) -> Self { + Self { + origin_x, + origin_y, + path: String::new(), + bounds: None, + } + } + + fn command(&mut self, command: char, points: &[(f32, f32)]) { + self.path.push(command); + for (index, (x, y)) in points.iter().enumerate() { + let x = self.origin_x + f64::from(*x); + let y = -(self.origin_y + f64::from(*y)); + let point = Bounds::point(x, y); + self.bounds = Some(self.bounds.map_or(point, |bounds| bounds.union(point))); + + if index > 0 { + self.path.push(' '); + } + let _ = write!(self.path, "{} {}", round(x), round(y)); + } + } +} + +impl OutlinePen for SvgPen { + fn move_to(&mut self, x: f32, y: f32) { + self.command('M', &[(x, y)]); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.command('L', &[(x, y)]); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.command('Q', &[(cx0, cy0), (x, y)]); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.command('C', &[(cx0, cy0), (cx1, cy1), (x, y)]); + } + + fn close(&mut self) { + self.path.push('Z'); + } +} + +fn round(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +#[cfg(test)] +mod tests; diff --git a/crates/shift-specimen/src/tests.rs b/crates/shift-specimen/src/tests.rs new file mode 100644 index 000000000..627327a00 --- /dev/null +++ b/crates/shift-specimen/src/tests.rs @@ -0,0 +1,160 @@ +use super::*; + +fn repo_font(path: &str) -> Vec { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + std::fs::read(root.join(path)).expect("fixture font should exist") +} + +fn coverage(letters: &[(Script, usize)], symbols: usize) -> Coverage { + let letters_by_script: HashMap> = letters + .iter() + .map(|(script, count)| (*script, vec!['x'; *count])) + .collect(); + let letter_count: usize = letters.iter().map(|(_, count)| count).sum(); + Coverage { + characters: letter_count + symbols, + symbols, + letters_by_script, + } +} + +#[test] +fn shows_ag_for_a_latin_font_with_both_cases() { + let specimen = specimen(&repo_font( + "packages/editor/src/ui/assets/fonts/Inter-VariableFont.ttf", + )) + .expect("Inter draws Ag"); + + assert_eq!(specimen.text, "Ag"); + assert!(!specimen.right_to_left); + assert!(specimen.outline.starts_with('M')); +} + +#[test] +fn shows_capitals_when_the_font_has_only_one_case() { + let specimen = specimen(&repo_font("fixtures/fonts/mutatorsans/MutatorSans.ttf")) + .expect("MutatorSans draws AG"); + + assert_eq!(specimen.text, "AG"); +} + +#[test] +fn fits_the_viewport_to_the_ink_with_a_minimum_height() { + let data = repo_font("fixtures/fonts/mutatorsans/MutatorSans.ttf"); + let font = SpecimenFont::new(&data).unwrap(); + let dot = Bounds { + min_x: 100.0, + min_y: -50.0, + max_x: 150.0, + max_y: 0.0, + }; + + let view_box = font.fit(dot); + + assert_eq!(view_box.height, font.units_per_em * MIN_VIEW_HEIGHT_EM); + assert!(view_box.x < dot.min_x && view_box.x + view_box.width > dot.max_x); + assert!(view_box.y < dot.min_y && view_box.y + view_box.height > dot.max_y); +} + +#[test] +fn returns_nothing_for_bytes_that_are_not_a_font() { + assert_eq!(specimen(b"not a font"), None); +} + +#[test] +fn a_non_latin_script_is_found_even_when_the_font_has_more_latin() { + let arabic_with_latin = coverage(&[(Script::Latin, 200), (Script::Arabic, 40)], 10); + + assert_eq!( + arabic_with_latin.main_script(), + Some(SpecimenScript::Arabic) + ); +} + +#[test] +fn a_script_needs_twenty_letters_to_lead() { + let a_few_hebrew_letters = coverage(&[(Script::Latin, 52), (Script::Hebrew, 19)], 0); + + assert_eq!(a_few_hebrew_letters.main_script(), None); +} + +#[test] +fn kana_marks_a_japanese_font_even_with_many_han_characters() { + let japanese = coverage( + &[ + (Script::Han, 6000), + (Script::Hiragana, 80), + (Script::Katakana, 90), + ], + 0, + ); + let chinese = coverage(&[(Script::Han, 6000)], 0); + + assert_eq!(japanese.main_script(), Some(SpecimenScript::Japanese)); + assert_eq!(chinese.main_script(), Some(SpecimenScript::Chinese)); +} + +#[test] +fn a_font_that_is_mostly_symbols_is_a_symbol_font() { + assert!(coverage(&[(Script::Latin, 10)], 11).is_symbol_font()); + assert!(!coverage(&[(Script::Latin, 10)], 10).is_symbol_font()); +} + +#[test] +fn resolves_a_design_language_by_script_subtag_or_language() { + let script = |tag| match SpecimenScript::from_language_tag(tag) { + DeclaredScript::Specimen(script) => Some(script), + DeclaredScript::Other => None, + }; + + assert_eq!(script("fa-Arab"), Some(SpecimenScript::Arabic)); + assert_eq!(script("zh-Hant"), Some(SpecimenScript::Chinese)); + assert_eq!(script("ko"), Some(SpecimenScript::Korean)); + assert_eq!(script("th"), Some(SpecimenScript::Thai)); + assert_eq!(script("sr-Latn"), None); + assert_eq!(script("en"), None); +} + +#[test] +fn plans_only_the_latin_candidates_for_a_latin_font() { + let characters: Vec = ('A'..='Z').chain('a'..='z').collect(); + + assert_eq!( + subset_plan(&characters), + SubsetPlan { + characters: vec!['A', 'G', 'a', 'g'], + needs_features: false, + } + ); +} + +#[test] +fn plans_the_script_pair_and_fallback_letters_for_a_non_latin_font() { + let hebrew: Vec = ('\u{05D0}'..='\u{05EA}').collect(); + let plan = subset_plan(&hebrew); + + assert!(plan.characters.contains(&'א') && plan.characters.contains(&'ב')); + assert!(plan.characters.len() <= 2 + SCRIPT_FALLBACK_LETTERS); + assert!(!plan.needs_features); +} + +#[test] +fn needs_the_whole_font_only_for_arabic_without_latin() { + let arabic: Vec = ('\u{0621}'..='\u{064A}').collect(); + let with_latin: Vec = arabic.iter().copied().chain(['A', 'g']).collect(); + + assert!(subset_plan(&arabic).needs_features); + assert!(!subset_plan(&with_latin).needs_features); +} + +#[test] +fn a_subset_with_the_full_character_map_draws_the_same_specimen() { + let data = repo_font("fixtures/fonts/mutatorsans/MutatorSans.ttf"); + let font = SpecimenFont::new(&data).unwrap(); + let characters: Vec = font.characters().collect(); + + assert_eq!( + specimen_with_characters(&data, &characters).map(|specimen| specimen.text), + Some("AG".to_string()) + ); +} diff --git a/packages/sdk/src/style.css b/packages/sdk/src/style.css index ae0f4a9cf..66354d004 100644 --- a/packages/sdk/src/style.css +++ b/packages/sdk/src/style.css @@ -27,6 +27,7 @@ --color-muted: var(--shift-color-muted, #5f5f5f); --color-sidebar-icon: var(--shift-color-sidebar-icon, #585858); --color-accent: var(--shift-color-accent, #0c92f4); + --color-on-accent: var(--shift-color-on-accent, #ffffff); --color-error: var(--shift-color-error, #dc2626); --color-error-ring: var(--shift-color-error-ring, #ef4444); --color-destructive: var(--shift-color-destructive, #dc2626); diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index 14c81fba3..279ec2a11 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -34,6 +34,21 @@ export type Unicode = number; export interface BridgeApi { createUntitledWorkspace(storePath: string, options?: NewWorkspace | undefined | null): void exportWorkspace(request: FontExportRequest): Promise + /** + * Builds the thumbnail specimen for a font file, independent of any + * document or preview this bridge holds. + * + * Resolves to `null` when the font draws nothing usable; rejects when the + * file cannot be read or compiled. + */ + fontFileSpecimen(path: string): Promise + /** + * Builds the thumbnail specimen from the open document's current state. + * + * Resolves to `null` when the font draws nothing usable; rejects when no + * workspace is open or compilation fails. + */ + documentSpecimen(): Promise documentState(): DocumentState inspectDocument(path: string): DocumentIdentity closeWorkspace(): void @@ -184,6 +199,20 @@ export interface NewWorkspace { familyName?: string unitsPerEm?: number } + +/** + * Specimen outline drawn on a recent file's thumbnail, in font units with y + * pointing down. + */ +export interface Specimen { + /** Characters shown; empty when glyphs were chosen by glyph order. */ + text: string + /** SVG path data. */ + outline: string + /** SVG `viewBox` as `[x, y, width, height]`, fitted to the ink. */ + viewBox: Array + rightToLeft: boolean +} export interface AddAnchorsIntent { layerId: LayerId anchors: Array diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts index 812f5fc75..8d1fe19bc 100644 --- a/packages/types/src/bridge/index.ts +++ b/packages/types/src/bridge/index.ts @@ -104,6 +104,7 @@ export type { SetMetricDefinitionsIntent, Source, SourceMetricValue, + Specimen, Unicode, UpdateAxisIntent, UpdateFontMetadataIntent, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1d398b11f..8620f3935 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -181,6 +181,7 @@ export type { SetMetricDefinitionsIntent, Source, SourceMetricValue, + Specimen, Unicode, UpdateAxisIntent, UpdateFontMetadataIntent, diff --git a/packages/ui/docs/DOCS.md b/packages/ui/docs/DOCS.md index f46a109b3..604e87bce 100644 --- a/packages/ui/docs/DOCS.md +++ b/packages/ui/docs/DOCS.md @@ -40,6 +40,7 @@ packages/ui/ slider/ -- Slider with thumb-level accessible name tabs/ -- Tabs root, list, tab, indicator, and panel textarea/ -- Multiline Field control + toggle-group/ -- ToggleGroup and icon-sized Toggle toolbar/ -- Toolbar root, group, button, and separator toast/ -- ToastProvider, ToastViewport, ToastRoot, ToastTitle, ToastDescription, ToastClose, useToastManager tooltip/ -- Tooltip, TooltipTrigger, TooltipContent, TooltipProvider @@ -53,7 +54,7 @@ packages/ui/ - **Form control props** -- `FieldLabelProps` adds a semantic `tone` (`"primary" | "secondary"`), while `FieldControlProps`, `TextareaProps`, `SelectTriggerProps`, and `NumberFieldGroupProps` add matching `variant` (`"filled" | "plain"`) contracts. Other form controls preserve their Base UI or native contracts while adding Shift styling. - **`TabsProps`** and tab-part props -- expose the Base UI Tabs composition; `TabsTabProps` adds `size` (`"sm" | "md"`) for shared tab typography and padding. - **`ToolbarProps`** and toolbar-part props -- expose Base UI's toolbar composition and roving keyboard focus for application tool strips. -- **`SeparatorProps`** -- adds `orientation` (`"horizontal" | "vertical"`) to the Base UI separator. +- **`SeparatorProps`** -- adds `orientation` (`"horizontal" | "vertical"`) and `variant` (`"subtle"` for panel regions, `"strong"` for dividers between small controls) to the Base UI separator. - **`ProgressProps`** -- extends Base UI Progress root props with track and indicator class overrides. - **Dialog and popover props** -- thin wrappers over Base UI sub-component props; `DialogClose` and `PopoverClose` add an `icon` variant for compact close actions. - **`CollapsibleProps`** / **`CollapsibleTriggerProps`** / **`CollapsiblePanelProps`** -- thin wrappers over Base UI Collapsible sub-component props. @@ -76,6 +77,8 @@ Each component follows the same pattern: import the Base UI primitive, wrap it i **Toast** is the most complex component family. `ToastProvider` wraps Base UI's provider with a default 2-second timeout. `ToastViewport` renders through a portal, centered at the top of the viewport. Individual toasts use enter/exit opacity transitions. Consumers call `useToastManager` (re-exported directly from Base UI) to imperatively add toasts. +**ToggleGroup** wraps Base UI's toggle group: pressing one `Toggle` releases the others unless `multiple` is set, and the group owns `aria-pressed` and arrow-key focus. `Toggle` is icon-sized and muted until hovered; a pressed toggle (`data-pressed`) sits on a filled background. + **Tooltip** supports an optional per-instance `delayDuration` override. When provided, it wraps the tooltip root in its own `TooltipProvider`; otherwise it inherits from the nearest ancestor `TooltipProvider`. The package also re-exports shared icons from `lucide-react` so app code does not need a direct icon dependency. diff --git a/packages/ui/src/components/button/Button.tsx b/packages/ui/src/components/button/Button.tsx index 0307a753d..cd406a521 100644 --- a/packages/ui/src/components/button/Button.tsx +++ b/packages/ui/src/components/button/Button.tsx @@ -13,7 +13,7 @@ export type ButtonProps = BaseButtonProps & { }; const variantStyles = { - primary: "rounded-md bg-accent text-white hover:bg-accent/90", + primary: "rounded-md bg-accent text-on-accent hover:bg-accent/90", default: "border border-line-subtle bg-background hover:bg-surface-hover", ghost: "hover:bg-hover/50 data-[active]:bg-hover/50", toolbar: diff --git a/packages/ui/src/components/select/Select.tsx b/packages/ui/src/components/select/Select.tsx index 2188ac0f2..c03d3f687 100644 --- a/packages/ui/src/components/select/Select.tsx +++ b/packages/ui/src/components/select/Select.tsx @@ -103,8 +103,8 @@ export const SelectItem = React.forwardRef< - {children ?? } + {children ?? } )); SelectItemIndicator.displayName = "SelectItemIndicator"; -export const SelectItemText = BaseSelect.ItemText; +export interface SelectItemTextProps extends React.ComponentPropsWithoutRef< + typeof BaseSelect.ItemText +> {} + +/** + * Option label, pinned to the item's second column so unselected options, + * which render no indicator, stay aligned with the selected one. + */ +export const SelectItemText = React.forwardRef< + React.ElementRef, + SelectItemTextProps +>(({ className, ...props }, ref) => ( + +)); +SelectItemText.displayName = "SelectItemText"; diff --git a/packages/ui/src/components/separator/Separator.tsx b/packages/ui/src/components/separator/Separator.tsx index 04a759eb3..2487c8fa0 100644 --- a/packages/ui/src/components/separator/Separator.tsx +++ b/packages/ui/src/components/separator/Separator.tsx @@ -4,16 +4,23 @@ import { cn } from "../../lib/utils"; export interface SeparatorProps extends React.ComponentPropsWithoutRef { orientation?: "horizontal" | "vertical"; + /** `subtle` divides panel regions; `strong` stays visible between small controls. */ + variant?: "subtle" | "strong"; } +const variantStyles = { + subtle: "bg-line-subtle", + strong: "bg-line/30", +}; + export const Separator = React.forwardRef( - ({ className, orientation = "horizontal", ...props }, ref) => { + ({ className, orientation = "horizontal", variant = "subtle", ...props }, ref) => { return ( {} + +/** Row of toggles where, unless `multiple` is set, pressing one releases the others. */ +export const ToggleGroup = React.forwardRef< + React.ElementRef, + ToggleGroupProps +>(({ className, ...props }, ref) => ( + +)); +ToggleGroup.displayName = "ToggleGroup"; + +export interface ToggleProps extends React.ComponentPropsWithoutRef {} + +/** Icon-sized toggle button: muted until hovered; pressed toggles sit on a filled background. */ +export const Toggle = React.forwardRef, ToggleProps>( + ({ className, ...props }, ref) => ( + + ), +); +Toggle.displayName = "Toggle"; diff --git a/packages/ui/src/components/toggle-group/index.ts b/packages/ui/src/components/toggle-group/index.ts new file mode 100644 index 000000000..ae4d190ad --- /dev/null +++ b/packages/ui/src/components/toggle-group/index.ts @@ -0,0 +1 @@ +export { Toggle, ToggleGroup, type ToggleProps, type ToggleGroupProps } from "./ToggleGroup"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0f7f3bac8..2a6f03173 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -135,6 +135,12 @@ export { type PopoverCloseProps, } from "./components/popover"; export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./components/tooltip"; +export { + Toggle, + ToggleGroup, + type ToggleProps, + type ToggleGroupProps, +} from "./components/toggle-group"; export { ToastProvider, ToastViewport, @@ -181,4 +187,4 @@ export { type PortalContainerProviderProps, } from "./components/portal"; export { cn } from "./lib/utils"; -export { Check, ChevronDown, Search, X } from "lucide-react"; +export { ArrowRight, Check, ChevronDown, LayoutGrid, List, Search, X } from "lucide-react";