From 00aeeea44833e2cc4e8f45d58c5384cc1795652b Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 08:48:28 +0100 Subject: [PATCH 01/11] fix(ui): align select options and keep their labels on one line --- packages/ui/src/components/select/Select.tsx | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/select/Select.tsx b/packages/ui/src/components/select/Select.tsx index 2188ac0f..c03d3f68 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"; From cd9348b07aebb23622bddd30883067defc9cade1 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 09:13:15 +0100 Subject: [PATCH 02/11] feat: show recent files with font previews in the launcher The launcher lists recently opened fonts as cards with a specimen drawn from each font, and File gains Open Recent with Clear Menu. Recents live in main, keyed by DocumentId with a path fallback, and are recorded on every open, Save As, and preview conversion. Specimens are chosen from the compiled font (symbol glyphs, a declared non-Latin design language, Ag, a script pair, then AG or ag), shaped with HarfRust at the default location, and cached per file. Documents and sources compile through the existing fontc exporter in the session's utility process, so the launcher never opens fonts itself. --- Cargo.lock | 53 +- Cargo.toml | 1 + apps/desktop/e2e/application-menu.spec.ts | 2 +- apps/desktop/e2e/document-lifecycle.spec.ts | 10 +- apps/desktop/e2e/handle-styling.spec.ts | 2 +- apps/desktop/e2e/platform-integration.spec.ts | 2 +- apps/desktop/e2e/preview-notice.spec.ts | 2 +- apps/desktop/e2e/recent-files.spec.ts | 92 +++ apps/desktop/playwright.config.ts | 1 + apps/desktop/src/main/app/App.ts | 124 +++- apps/desktop/src/main/menu/ApplicationMenu.ts | 14 +- apps/desktop/src/main/menu/menuItems.test.ts | 59 ++ apps/desktop/src/main/menu/menuItems.ts | 39 ++ .../src/main/recents/RecentDocuments.test.ts | 145 +++++ .../src/main/recents/RecentDocuments.ts | 254 ++++++++ .../src/main/workspace/WorkspaceManager.ts | 42 ++ .../src/main/workspace/WorkspaceProcess.ts | 12 + apps/desktop/src/preload/preload.ts | 9 + apps/desktop/src/renderer/index.css | 11 + apps/desktop/src/renderer/src/app/branding.ts | 8 +- .../src/assets/launcher-lockup-nightly.svg | 34 ++ .../renderer/src/assets/launcher-lockup.svg | 34 ++ .../src/assets/launcher-logo-nightly.svg | 41 -- .../src/renderer/src/assets/launcher-logo.svg | 41 -- .../components/launcher/RecentFileItem.tsx | 222 +++++++ .../components/launcher/RecentFileMenu.tsx | 64 ++ .../src/components/launcher/RecentFiles.tsx | 150 +++++ .../launcher/recentFileName.test.ts | 21 + .../src/components/launcher/recentFileName.ts | 32 + .../components/launcher/useRecentDocuments.ts | 55 ++ .../launcher/useRecentFileActions.ts | 60 ++ .../src/renderer/src/lib/themes/index.ts | 2 + .../src/renderer/src/views/Landing.tsx | 71 +-- .../src/renderer/src/views/RecentFiles.tsx | 7 - apps/desktop/src/shared/host/ShiftHost.ts | 31 + apps/desktop/src/shared/ipc/contract.ts | 12 + apps/desktop/src/shared/recents.test.ts | 46 ++ apps/desktop/src/shared/recents.ts | 107 ++++ apps/desktop/src/shared/workspace/protocol.ts | 7 + .../src/utility/workspace/WorkspaceHost.ts | 15 +- crates/shift-backends/src/export.rs | 20 +- crates/shift-bridge/Cargo.toml | 1 + crates/shift-bridge/index.d.ts | 29 + crates/shift-bridge/src/bridge.rs | 27 + crates/shift-bridge/src/lib.rs | 1 + crates/shift-bridge/src/specimen.rs | 93 +++ crates/shift-specimen/Cargo.toml | 14 + crates/shift-specimen/src/lib.rs | 571 ++++++++++++++++++ crates/shift-specimen/src/tests.rs | 116 ++++ packages/types/src/bridge/generated.ts | 29 + packages/types/src/bridge/index.ts | 1 + packages/types/src/index.ts | 1 + packages/ui/src/index.ts | 2 +- 53 files changed, 2668 insertions(+), 171 deletions(-) create mode 100644 apps/desktop/e2e/recent-files.spec.ts create mode 100644 apps/desktop/src/main/recents/RecentDocuments.test.ts create mode 100644 apps/desktop/src/main/recents/RecentDocuments.ts create mode 100644 apps/desktop/src/renderer/src/assets/launcher-lockup-nightly.svg create mode 100644 apps/desktop/src/renderer/src/assets/launcher-lockup.svg delete mode 100644 apps/desktop/src/renderer/src/assets/launcher-logo-nightly.svg delete mode 100644 apps/desktop/src/renderer/src/assets/launcher-logo.svg create mode 100644 apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx create mode 100644 apps/desktop/src/renderer/src/components/launcher/RecentFileMenu.tsx create mode 100644 apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx create mode 100644 apps/desktop/src/renderer/src/components/launcher/recentFileName.test.ts create mode 100644 apps/desktop/src/renderer/src/components/launcher/recentFileName.ts create mode 100644 apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts create mode 100644 apps/desktop/src/renderer/src/components/launcher/useRecentFileActions.ts delete mode 100644 apps/desktop/src/renderer/src/views/RecentFiles.tsx create mode 100644 apps/desktop/src/shared/recents.test.ts create mode 100644 apps/desktop/src/shared/recents.ts create mode 100644 crates/shift-bridge/src/specimen.rs create mode 100644 crates/shift-specimen/Cargo.toml create mode 100644 crates/shift-specimen/src/lib.rs create mode 100644 crates/shift-specimen/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 392877fd..e474509a 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 40b5a005..b4b6657e 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/application-menu.spec.ts b/apps/desktop/e2e/application-menu.spec.ts index 3f8c7635..ac45e15e 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 98a3e535..f5227165 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/handle-styling.spec.ts b/apps/desktop/e2e/handle-styling.spec.ts index cab2581a..85ef0f81 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/platform-integration.spec.ts b/apps/desktop/e2e/platform-integration.spec.ts index ce29254d..4fa9deff 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 a90ae583..1ed8df66 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 00000000..c9a6197a --- /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/playwright.config.ts b/apps/desktop/playwright.config.ts index a94bb3d0..e87db058 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 4a90ee7f..336995c3 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,15 @@ 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 = 940; +const LAUNCHER_WIDTH = 1040; +const LAUNCHER_HEIGHT = 720; /** * Owns Electron app startup and the first main-process service graph. @@ -55,6 +60,7 @@ export class App { #windows = new WindowManager(); #workspaces: WorkspaceManager; #documentsRoot: string | null = null; + #recents: RecentDocuments | null = null; #pendingOpenPaths: string[] = []; #previewConversions = new Map>(); #documentCrashDecisions = new Map>(); @@ -86,6 +92,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 +124,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 +204,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) { @@ -277,7 +297,7 @@ export class App { } #openLauncher(): Window { - const window = this.#createWindow(true, undefined, false, LAUNCHER_MIN_WIDTH); + const window = this.#createWindow(true, launcherBounds(), false, LAUNCHER_MIN_WIDTH); this.#loadLauncher(window); return window; } @@ -416,6 +436,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"); @@ -656,17 +700,72 @@ 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; + + await this.#openPathFromWindow(opener, openPath); + } - const session = await this.#workspaces.openPath(openPath); - if (this.#focusExistingWorkspaceWindow(opener, session)) return; + /** + * 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 { + 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); await this.#nativeDialogs.showOpenFailure(opener, this.applicationName); + return false; + } + } + + #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()) continue; + ipc.send(window.window.webContents, "recents.changed", documents); } } @@ -716,3 +815,16 @@ export class App { return window; } } + +/** Centres the launcher on the primary display, shrinking it to fit smaller screens. */ +function launcherBounds(): Rectangle { + const workArea = screen.getPrimaryDisplay().workArea; + const width = Math.min(LAUNCHER_WIDTH, workArea.width); + const height = Math.min(LAUNCHER_HEIGHT, workArea.height); + 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 042b859b..0ba6c852 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 397a7fb2..4d45bcce 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, missing }; +} + +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 9c477991..777108f5 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 00000000..bfdcc365 --- /dev/null +++ b/apps/desktop/src/main/recents/RecentDocuments.test.ts @@ -0,0 +1,145 @@ +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, 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, missing: false, specimen }, + ]); + }); + + 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 00000000..0492daeb --- /dev/null +++ b/apps/desktop/src/main/recents/RecentDocuments.ts @@ -0,0 +1,254 @@ +import fs from "node:fs"; +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 = 3; + +/** 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 #listeners = new Set<() => void>(); + #entries: RecentEntry[]; + + /** + * Loads recents persisted at `filePath`. + * + * @param filePath - JSON file that stores the list across launches. + */ + constructor(filePath: string) { + this.#filePath = filePath; + 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, + 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; +} + +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/workspace/WorkspaceManager.ts b/apps/desktop/src/main/workspace/WorkspaceManager.ts index 4b68c1ef..8da25f6e 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 20c4d388..50b8868b 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 91f897f7..2a11761e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -38,6 +38,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 00abce42..53618db9 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -64,6 +64,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 +113,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 455089c1..296c7294 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 00000000..d69498b3 --- /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 00000000..ac06fb0b --- /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 cfb36cbf..00000000 --- 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 b37abd7a..00000000 --- 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 00000000..98a545ab --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx @@ -0,0 +1,222 @@ +import { Fragment, useLayoutEffect, useRef, useState } from "react"; +import { Button, cn, Plus, Tooltip, TooltipContent, TooltipTrigger } from "@shift/ui"; +import { pathBasename, recentOpenedLabel, type RecentDocument } from "@shared/recents"; +import { RecentFileMenu } from "./RecentFileMenu"; +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/60 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"; + +/** + * First grid card: creates a new font. A plain panel with a plus that the + * grid stretches to the height of the recent-file cards beside it. + */ +export const NewFontCard = ({ onCreate }: { onCreate: () => void }) => ( +
  • + + +
  • +); + +/** + * 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 ( +
  • + + +
  • + ); +}; 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 00000000..7489200f --- /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 00000000..a1b1ea68 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx @@ -0,0 +1,150 @@ +import { useMemo, useState } from "react"; +import { + ArrowRight, + Button, + ToastProvider, + ToastRoot, + ToastTitle, + ToastViewport, + useToastManager, +} from "@shift/ui"; +import { recentFolderLabels, type RecentDocument } from "@shared/recents"; +import { NewFontCard, RecentFileCard } from "./RecentFileItem"; +import { getShiftHost } from "@/host/shiftHost"; +import { useMinuteClock } from "./useRecentDocuments"; +import { + useRecentFileActions, + type RecentFileActions, + type RemovedRecentToast, +} from "./useRecentFileActions"; + +/** Recent files shown before "View all"; with the New font card, two full grid rows. */ +const COLLAPSED_COUNT = 9; +const UNDO_TIMEOUT_MS = 6000; + +interface RecentFilesProps { + documents: readonly RecentDocument[]; +} + +/** + * Launcher section: Open Font plus a grid of recently opened files. + * + * @remarks + * Newest first after a New font card, showing nine until "View all" is chosen. + */ +export const RecentFiles = ({ documents }: RecentFilesProps) => ( + + + + + + +); + +const RecentFilesSection = ({ documents }: RecentFilesProps) => { + const actions = useRecentFileActions(); + const now = useMinuteClock(); + const [expanded, setExpanded] = useState(false); + + 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; + actions: RecentFileActions; +} + +const RecentFileCollection = ({ documents, folders, now, actions }: RecentFileCollectionProps) => { + return ( +
      + + {documents.map((document) => ( + + ))} +
    + ); +}; + +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); + } +} 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 00000000..90069708 --- /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 00000000..0d56e919 --- /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 00000000..4d627565 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts @@ -0,0 +1,55 @@ +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; +} 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 00000000..29d8f682 --- /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.ts b/apps/desktop/src/renderer/src/lib/themes/index.ts index 4107ebdd..068c0516 100644 --- a/apps/desktop/src/renderer/src/lib/themes/index.ts +++ b/apps/desktop/src/renderer/src/lib/themes/index.ts @@ -298,6 +298,8 @@ 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, diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index fed74963..f1696410 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -1,64 +1,27 @@ -import { Button, Separator } from "@shift/ui"; -import { LauncherLogo } from "@/app/branding"; +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 handleNewFont = async () => { - try { - await host.commands.run("file.new"); - } catch (error) { - console.error("new font failed", error); - } - }; - - const handleOpenFont = async () => { - try { - await host.commands.run("file.open"); - } catch (error) { - console.error("opening a font failed", error); - } - }; + const recentDocuments = useRecentDocuments(); return ( -
    - -
    -
    -
    -
    - - -
    -
    - - {}} /> +
    + {/* Overlaid so the launcher centres against the whole window, not the space below the bar. */} +
    + +
    +
    +
    +
    +
    + {recentDocuments && }
    -
    +
    ); }; 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 d808adb4..00000000 --- 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 5f8b660b..8cf832f0 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. @@ -105,6 +106,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 899c848f..43d7bb25 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 }; @@ -62,6 +63,15 @@ export type RendererToMain = { "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 +92,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 00000000..d6e71bb2 --- /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 00000000..501707f0 --- /dev/null +++ b/apps/desktop/src/shared/recents.ts @@ -0,0 +1,107 @@ +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; + /** 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 e223df8a..22564413 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 f281a178..cc1c7bf1 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 153bc21b..5ba181e6 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-bridge/Cargo.toml b/crates/shift-bridge/Cargo.toml index 25d9ff40..760666e0 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 e82a580a..55285796 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 f3034fee..4b4186a4 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 56e19475..ba5ef831 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 00000000..727a9d1a --- /dev/null +++ b/crates/shift-bridge/src/specimen.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; + +use napi::bindgen_prelude::*; +use napi::{Error, Status}; +use napi_derive::napi; +use shift_backends::{font_loader::FontLoader, FontExporter}; + +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 when needed, then chooses and outlines the thumbnail specimen. +pub struct SpecimenTask { + pub(crate) input: SpecimenInput, +} + +impl SpecimenTask { + fn font_binary(&self) -> std::result::Result, String> { + match &self.input { + SpecimenInput::Snapshot(snapshot) => FontExporter::new() + .compile_ttf(snapshot) + .map_err(|error| error.to_string()), + SpecimenInput::Path(path) if is_font_binary(path) => { + std::fs::read(path).map_err(|error| error.to_string()) + } + SpecimenInput::Path(path) => { + let font = FontLoader::new() + .read_font(&path.to_string_lossy()) + .map_err(|error| error.to_string())?; + FontExporter::new() + .compile_ttf(&font) + .map_err(|error| error.to_string()) + } + } + } +} + +impl Task for SpecimenTask { + type Output = Option; + type JsValue = Option; + + fn compute(&mut self) -> Result { + let binary = self + .font_binary() + .map_err(|message| Error::new(Status::GenericFailure, message))?; + Ok(shift_specimen::specimen(&binary)) + } + + 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 00000000..fa477f28 --- /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 00000000..bfdc671c --- /dev/null +++ b/crates/shift-specimen/src/lib.rs @@ -0,0 +1,571 @@ +//! 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::of(&font); + + 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()) +} + +/// 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, + } + } + + 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 of(font: &SpecimenFont) -> Self { + let mut coverage = Coverage { + characters: 0, + symbols: 0, + letters_by_script: HashMap::new(), + }; + + for character in font.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' ScriptLangTag splitting mis-measures tags after the + // first, so the comma-separated list is split here. + 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 00000000..fd527f50 --- /dev/null +++ b/crates/shift-specimen/src/tests.rs @@ -0,0 +1,116 @@ +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); +} diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index 14c81fba..279ec2a1 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 812f5fc7..8d1fe19b 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 1d398b11..8620f393 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/src/index.ts b/packages/ui/src/index.ts index 0f7f3bac..a52a31a9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -181,4 +181,4 @@ export { type PortalContainerProviderProps, } from "./components/portal"; export { cn } from "./lib/utils"; -export { Check, ChevronDown, Search, X } from "lucide-react"; +export { ArrowRight, Check, ChevronDown, Plus, Search, X } from "lucide-react"; From 6f6d8871639485dad05f28ce7e708b4781ef24df Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 10:18:51 +0100 Subject: [PATCH 03/11] fix: keep primary button text readable on light accent colours Themes now choose an on-accent text colour, white or the palette's darkest colour, by contrast with their accent, so primary buttons stay legible on palettes such as Dracula whose accent is pale. --- apps/desktop/src/renderer/index.css | 1 + .../src/renderer/src/lib/themes/index.test.ts | 2 ++ .../src/renderer/src/lib/themes/index.ts | 26 +++++++++++++++++++ packages/sdk/src/style.css | 1 + packages/ui/src/components/button/Button.tsx | 2 +- 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 53618db9..c6b8ecbb 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; 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 98baee66..57c77ded 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 068c0516..d20af88c 100644 --- a/apps/desktop/src/renderer/src/lib/themes/index.ts +++ b/apps/desktop/src/renderer/src/lib/themes/index.ts @@ -305,6 +305,10 @@ function colorThemeVariables(theme: ColorTheme): Record { "--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, @@ -359,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/packages/sdk/src/style.css b/packages/sdk/src/style.css index ae0f4a9c..66354d00 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/ui/src/components/button/Button.tsx b/packages/ui/src/components/button/Button.tsx index 0307a753..cd406a52 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: From dddcc55e6657f9a4501c9386fa1226be37bc46be Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 10:19:57 +0100 Subject: [PATCH 04/11] feat: refine the launcher layout and add a list view The launcher opens as a 4:3 window that appears only once its recent files have rendered, with the lockup and Recent header at a fixed position so switching between the new grid and list views never moves them. New Font becomes a primary header button beside Open Font and the view toggle, replacing the New font card. Opening a font no longer flashes a blank card in the launcher it replaces. Adds shared ToggleGroup and Toggle components and a strong Separator variant. --- .../desktop/e2e/fixtures/documentLifecycle.ts | 2 +- apps/desktop/e2e/landing.spec.ts | 2 +- apps/desktop/e2e/window-behavior.spec.ts | 2 +- apps/desktop/src/main/app/App.ts | 79 ++++++-- apps/desktop/src/main/windows/Window.ts | 6 +- apps/desktop/src/preload/preload.ts | 1 + .../components/launcher/RecentFileItem.tsx | 72 ++++--- .../src/components/launcher/RecentFiles.tsx | 184 +++++++++++++++--- .../src/renderer/src/views/Landing.tsx | 25 ++- apps/desktop/src/shared/host/ShiftHost.ts | 8 + apps/desktop/src/shared/ipc/contract.ts | 2 + packages/ui/docs/DOCS.md | 5 +- .../ui/src/components/separator/Separator.tsx | 11 +- .../components/toggle-group/ToggleGroup.tsx | 35 ++++ .../ui/src/components/toggle-group/index.ts | 1 + packages/ui/src/index.ts | 8 +- 16 files changed, 370 insertions(+), 73 deletions(-) create mode 100644 packages/ui/src/components/toggle-group/ToggleGroup.tsx create mode 100644 packages/ui/src/components/toggle-group/index.ts diff --git a/apps/desktop/e2e/fixtures/documentLifecycle.ts b/apps/desktop/e2e/fixtures/documentLifecycle.ts index afc48948..f4a752b8 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/landing.spec.ts b/apps/desktop/e2e/landing.spec.ts index 21d53484..7256c68e 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/window-behavior.spec.ts b/apps/desktop/e2e/window-behavior.spec.ts index 30c3166c..fb6c4982 100644 --- a/apps/desktop/e2e/window-behavior.spec.ts +++ b/apps/desktop/e2e/window-behavior.spec.ts @@ -5,7 +5,7 @@ import { runCommand } from "./fixtures/documentLifecycle"; 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(); + await expect(page.getByRole("button", { name: "New Font", exact: true })).toBeVisible(); const browserWindow = await electronApp.browserWindow(page); try { diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index 336995c3..92768419 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -36,9 +36,12 @@ 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 = 940; -const LAUNCHER_WIDTH = 1040; +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. @@ -61,6 +64,9 @@ export class App { #workspaces: WorkspaceManager; #documentsRoot: string | null = null; #recents: RecentDocuments | null = null; + #fontOpensInFlight = 0; + #recentsPublishDeferred = false; + #replacedLaunchers = new WeakSet(); #pendingOpenPaths: string[] = []; #previewConversions = new Map>(); #documentCrashDecisions = new Map>(); @@ -296,12 +302,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, launcherBounds(), 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"); } @@ -491,6 +512,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); @@ -665,7 +689,7 @@ export class App { const sourcePath = this.#pendingOpenPaths[0]; try { - const session = await this.#workspaces.openPath(sourcePath); + const session = await this.#openFontPath(sourcePath); const opener = this.#windows.activeWindow() ?? this.#windows @@ -720,7 +744,7 @@ export class App { */ async #openPathFromWindow(opener: Window, sourcePath: string): Promise { try { - const session = await this.#workspaces.openPath(sourcePath); + const session = await this.#openFontPath(sourcePath); if (this.#focusExistingWorkspaceWindow(opener, session)) return true; this.#openWorkspaceWindow(opener, session); @@ -759,12 +783,38 @@ export class App { } } + /** + * Opens a font while holding back recents updates from renderer windows. + * + * @remarks + * Opening records the file before its window exists, so the launcher would + * briefly show a new card with no preview. Updates wait until every open has + * settled; by then a successful open has already replaced the launcher. + */ + async #openFontPath(sourcePath: string): Promise { + this.#fontOpensInFlight += 1; + try { + return await this.#workspaces.openPath(sourcePath); + } finally { + this.#fontOpensInFlight -= 1; + if (this.#fontOpensInFlight === 0 && this.#recentsPublishDeferred) { + this.#recentsPublishDeferred = false; + // After the caller has shown the font and closed the launcher it replaced. + setTimeout(() => this.#publishRecents(), 0); + } + } + } + #publishRecents(): void { this.#applicationMenu.refresh(); + if (this.#fontOpensInFlight > 0) { + this.#recentsPublishDeferred = true; + return; + } const documents = this.#recents?.list() ?? []; for (const window of this.#windows.allWindows()) { - if (window.window.isDestroyed()) continue; + if (window.window.isDestroyed() || this.#replacedLaunchers.has(window)) continue; ipc.send(window.window.webContents, "recents.changed", documents); } } @@ -774,7 +824,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; } @@ -787,7 +839,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 { @@ -816,11 +873,11 @@ export class App { } } -/** Centres the launcher on the primary display, shrinking it to fit smaller screens. */ +/** 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, workArea.width); - const height = Math.min(LAUNCHER_HEIGHT, workArea.height); + 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), diff --git a/apps/desktop/src/main/windows/Window.ts b/apps/desktop/src/main/windows/Window.ts index 35f63852..34701cd8 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/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2a11761e..2fb111f2 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"), diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx index 98a545ab..c2b8b43b 100644 --- a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx @@ -1,5 +1,5 @@ import { Fragment, useLayoutEffect, useRef, useState } from "react"; -import { Button, cn, Plus, Tooltip, TooltipContent, TooltipTrigger } from "@shift/ui"; +import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from "@shift/ui"; import { pathBasename, recentOpenedLabel, type RecentDocument } from "@shared/recents"; import { RecentFileMenu } from "./RecentFileMenu"; import type { RecentFileActions } from "./useRecentFileActions"; @@ -140,27 +140,6 @@ const CARD_BUTTON_CLASS = 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"; -/** - * First grid card: creates a new font. A plain panel with a plus that the - * grid stretches to the height of the recent-file cards beside it. - */ -export const NewFontCard = ({ onCreate }: { onCreate: () => void }) => ( -
  • - - -
  • -); - /** * Grid card for one recent file: specimen, filename, and when it was opened. * @@ -192,16 +171,16 @@ export const RecentFileCard = ({ document, folder, now, actions }: RecentFileIte
    -
    +
    @@ -220,3 +199,46 @@ export const RecentFileCard = ({ document, folder, now, actions }: RecentFileIte ); }; + +/** List row for one recent file: specimen chip, name, full path, and when it was opened. */ +export const RecentFileRow = ({ document, folder, now, actions }: RecentFileItemProps) => { + const name = pathBasename(document.path); + const activate = document.missing ? actions.locate : actions.open; + + return ( +
  • + + {document.missing && } + +
  • + ); +}; diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx index a1b1ea68..515e6b63 100644 --- a/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFiles.tsx @@ -2,14 +2,22 @@ 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 { NewFontCard, RecentFileCard } from "./RecentFileItem"; +import { RecentFileCard, RecentFileRow } from "./RecentFileItem"; import { getShiftHost } from "@/host/shiftHost"; import { useMinuteClock } from "./useRecentDocuments"; import { @@ -18,19 +26,24 @@ import { type RemovedRecentToast, } from "./useRecentFileActions"; -/** Recent files shown before "View all"; with the New font card, two full grid rows. */ -const COLLAPSED_COUNT = 9; +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 a grid of recently opened files. + * Launcher section: Open Font plus recently opened files as a grid or list. * * @remarks - * Newest first after a New font card, showing nine until "View all" is chosen. + * 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) => ( @@ -45,6 +58,7 @@ 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)), @@ -59,25 +73,48 @@ const RecentFilesSection = ({ documents }: RecentFilesProps) => {

    Recent

    - +
    + + + + +
    - - {collapsed && ( - - )} +
    + + {collapsed && ( + + )} +
    ); }; @@ -86,13 +123,39 @@ interface RecentFileCollectionProps { documents: readonly RecentDocument[]; folders: Map; now: number; + view: RecentView; actions: RecentFileActions; } -const RecentFileCollection = ({ documents, folders, now, actions }: RecentFileCollectionProps) => { +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) => ( 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(); @@ -148,3 +254,29 @@ async function createFont(): Promise { 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/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index f1696410..651e1432 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -1,11 +1,24 @@ +import { useEffect } from "react"; import { LauncherLockup } from "@/app/branding"; import { shiftProductName } from "@/app/release"; 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 recentDocuments = useRecentDocuments(); + const recentsLoaded = recentDocuments !== null; + + useEffect(() => { + if (!recentsLoaded) return undefined; + + // 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 (
        @@ -14,9 +27,9 @@ export const Landing = () => {
    -
    +
    -
    {recentDocuments && } @@ -25,3 +38,11 @@ export const Landing = () => { ); }; + +async function signalReady(): Promise { + try { + await getShiftHost().window.ready(); + } catch (error) { + console.error("signalling launcher readiness failed", error); + } +} diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 8cf832f0..57001bbe 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -92,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: { diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index 43d7bb25..ca1c233c 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -58,6 +58,8 @@ 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; diff --git a/packages/ui/docs/DOCS.md b/packages/ui/docs/DOCS.md index f46a109b..604e87bc 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/separator/Separator.tsx b/packages/ui/src/components/separator/Separator.tsx index 04a759eb..2487c8fa 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 00000000..ae4d190a --- /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 a52a31a9..2a6f0317 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 { ArrowRight, Check, ChevronDown, Plus, Search, X } from "lucide-react"; +export { ArrowRight, Check, ChevronDown, LayoutGrid, List, Search, X } from "lucide-react"; From b704d1770d7e294a4a92d5bc94aa9e2fbcd71127 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 10:23:15 +0100 Subject: [PATCH 05/11] fix: soften launcher card borders --- .../src/renderer/src/components/launcher/RecentFileItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx index c2b8b43b..f9ea56ce 100644 --- a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx @@ -134,7 +134,7 @@ const MissingActions = ({ ); const CARD_CLASS = - "group relative flex min-w-0 flex-col overflow-hidden border border-line/60 bg-background card-shadow transition-shadow hover:shadow-md"; + "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 = @@ -222,7 +222,7 @@ export const RecentFileRow = ({ document, folder, now, actions }: RecentFileItem > From 7112557e5282565fc9c488ae6ce444dd1c93127f Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 10:55:32 +0100 Subject: [PATCH 06/11] test(e2e): expect the launcher's 4:3 size capped to the screen --- apps/desktop/e2e/window-behavior.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/window-behavior.spec.ts b/apps/desktop/e2e/window-behavior.spec.ts index fb6c4982..ee47aeba 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 }) => { +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(); } From 519aa1c2e4b54d709607c83ac01af46ed076e4c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:16:28 +0000 Subject: [PATCH 07/11] test(e2e): update visual snapshots --- .../landing.spec.ts/landing-default.png | Bin 8459 -> 11589 bytes .../tools.spec.ts/tool-pen.png | Bin 1327 -> 1320 bytes 2 files changed, 0 insertions(+), 0 deletions(-) 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 137c9d99615c55e249876be57de7c123b5c0c9a1..03b91a7eb0e67a64736d409a49f6dc4752a8612d 100644 GIT binary patch literal 11589 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 literal 8459 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 delta 1308 zcmV+%1>^dt3a<)~B!A>dL_t(|oa~&>ZyQAv$KQG#$4W4@5=9A0e*m{eqLsK5S%jof zP9PzXQx3f%^*~XCxRgJD9ta_*7sQ!EPDbL;6N!XUjM^JLgc}HpRE{D`DUL1I_Qw6% zubHfD*V%Dq)|>TwWF_m#dg7h=?swjso!w|Qo26r7vDoP7Xn#B&k4B?p7z}E)TDe>< z7K@ciMHHTNKLjy0Hb#a40~YXFlR}|zaB%RlkbjMjkB^LukYRvHBoel5=ks}NN2uu& zMiT}A86=ZQY(x=H1ZV8{>95)ke^#IDAJwUMETU@@k=xgYZe2AIk~Hv)QmJHNM8UX1 z{pO!%^)G&V{(tMg4yj}DioG^B{MH4F3^E)Z9$M9EH3)gfC%=@{YJ!0>e*QR41{si> zM1r6Rj2${O0Z=U}CxZ--M1%qW;tA4**2=?5Ac8W)n4}G@m4}r;$l@${hi{cHEiGj- znJ=4=2DY}g_V@RtJ~A;ev9Pc}8VGI*w>Cfg={wPTkAE9Oz}Jr|)F(2>q*AGLIt_8G zuCAgy)r1rQO>Asz%*@P4eQI-a6F+q*Be=4FCjNeQbU9&V3-!XC(SGRzB7i-xEZpVL zM=qBmMZhPZiItTVsZXu1uge;YN4$odXdN`f)YKH8D?YHj>-_kn_TC$jhr3SVevu2I zkFRdT@P8s-z`>@D5rx=>P!w840P!jdoG!3XK0nwPOMmZ8ugouJ}Gfj;jQckUB(vLmBXlZ$5~9{_apqRKdfILc_q3a(o@ixWNlO z4kE8Yt*>%_3;uZOJkA|0UT%b+Edz&I!+__dn}|ae$%u%n&LhIhPc?O~^A>1}?y|sN z#eX~xDFxjx^zr5ei$|7+yGOGZPs*`zmsE};JW(NzrTw7JV_+cy%bqosj_ z8u*_n*96?|)Y z>sW>(Eejp=APXsamIcEX0$W@`xlaUY(Ow{A@%sIuzAs@B{eL!D3?PBHA>i7C#;>8Z z@~{$!CcAxI<7IKJm4}r`t=9jWu77S_jV@l%vW^DTnj4g5Y20jBE|)0)tj!H8R|emj zOa@6H2`iV&O-@cyFfb1?2m*V1dsa4^O(v6xM1l+kB=gNjB{f1${3?}7WEglL1Actg zOPkH-^N!;f&lqGnJW`F0J;OYJanR`a0LB*p00960Zm67700006Nkl Date: Wed, 30 Sep 2026 11:01:28 +0100 Subject: [PATCH 08/11] perf: compile only the glyphs a font preview needs Specimens for documents and sources were compiled from the whole font. The specimen rules now plan from the character map which characters they could shape, and only those glyphs (with their components, .notdef, and the first drawn glyphs) are compiled, without features or kerning. Fonts whose specimen needs contextual forms (Arabic without Latin) still compile whole. On Fraunces (697 glyphs) the compile drops from 38 ms to 2 ms. --- crates/shift-backends/src/glyph_subset.rs | 131 ++++++++++++++++++++++ crates/shift-backends/src/lib.rs | 2 + crates/shift-bridge/src/specimen.rs | 85 +++++++++++--- crates/shift-specimen/src/lib.rs | 87 ++++++++++++-- crates/shift-specimen/src/tests.rs | 44 ++++++++ 5 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 crates/shift-backends/src/glyph_subset.rs diff --git a/crates/shift-backends/src/glyph_subset.rs b/crates/shift-backends/src/glyph_subset.rs new file mode 100644 index 00000000..04720628 --- /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 0c5c0d9b..ecf8ffba 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/src/specimen.rs b/crates/shift-bridge/src/specimen.rs index 727a9d1a..1eeaab89 100644 --- a/crates/shift-bridge/src/specimen.rs +++ b/crates/shift-bridge/src/specimen.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use napi::bindgen_prelude::*; use napi::{Error, Status}; use napi_derive::napi; -use shift_backends::{font_loader::FontLoader, FontExporter}; +use shift_backends::{font_loader::FontLoader, FontExporter, FontView, GlyphSubsetView}; +use shift_font::Glyph; use crate::bridge::FontSaveSnapshot; @@ -41,41 +42,97 @@ pub(crate) enum SpecimenInput { Path(PathBuf), } -/// Compiles when needed, then chooses and outlines the thumbnail specimen. +/// Compiles what the specimen needs, then chooses and outlines it. pub struct SpecimenTask { pub(crate) input: SpecimenInput, } impl SpecimenTask { - fn font_binary(&self) -> std::result::Result, String> { + fn build(&self) -> std::result::Result, String> { match &self.input { - SpecimenInput::Snapshot(snapshot) => FontExporter::new() - .compile_ttf(snapshot) - .map_err(|error| error.to_string()), + SpecimenInput::Snapshot(snapshot) => source_specimen(snapshot), SpecimenInput::Path(path) if is_font_binary(path) => { - std::fs::read(path).map_err(|error| error.to_string()) + 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())?; - FontExporter::new() - .compile_ttf(&font) - .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 { - let binary = self - .font_binary() - .map_err(|message| Error::new(Status::GenericFailure, message))?; - Ok(shift_specimen::specimen(&binary)) + self + .build() + .map_err(|message| Error::new(Status::GenericFailure, message)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { diff --git a/crates/shift-specimen/src/lib.rs b/crates/shift-specimen/src/lib.rs index bfdc671c..23331c53 100644 --- a/crates/shift-specimen/src/lib.rs +++ b/crates/shift-specimen/src/lib.rs @@ -66,13 +66,74 @@ pub struct ViewBox { /// 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::of(&font); + 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) { + if let Some(specimen) = font.script_specimen(script, coverage) { return Some(specimen); } } @@ -80,7 +141,7 @@ pub fn specimen(font_data: &[u8]) -> Option { return Some(specimen); } if let Some(script) = coverage.main_script() { - if let Some(specimen) = font.script_specimen(script, &coverage) { + if let Some(specimen) = font.script_specimen(script, coverage) { return Some(specimen); } } @@ -91,6 +152,10 @@ pub fn specimen(font_data: &[u8]) -> Option { .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 { @@ -184,6 +249,11 @@ impl SpecimenScript { } } + /// 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, @@ -215,14 +285,14 @@ struct Coverage { } impl Coverage { - fn of(font: &SpecimenFont) -> Self { + fn from_characters(characters: impl IntoIterator) -> Self { let mut coverage = Coverage { characters: 0, symbols: 0, letters_by_script: HashMap::new(), }; - for character in font.characters() { + for character in characters { if character.is_whitespace() || character.is_control() { continue; } @@ -312,8 +382,11 @@ impl<'a> SpecimenFont<'a> { .data_maps() .iter() .find(|record| record.tag() == Tag::new(b"dlng"))?; - // read-fonts' ScriptLangTag splitting mis-measures tags after the - // first, so the comma-separated list is split here. + // 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)?; diff --git a/crates/shift-specimen/src/tests.rs b/crates/shift-specimen/src/tests.rs index fd527f50..627327a0 100644 --- a/crates/shift-specimen/src/tests.rs +++ b/crates/shift-specimen/src/tests.rs @@ -114,3 +114,47 @@ fn resolves_a_design_language_by_script_subtag_or_language() { 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()) + ); +} From 6cb76e5e7add083844becebb97b8618aef6f4ab0 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 11:02:03 +0100 Subject: [PATCH 09/11] refactor(desktop): simplify sparing a replaced launcher from recents updates --- apps/desktop/src/main/app/App.ts | 44 ++++++------------- .../src/main/recents/RecentDocuments.ts | 2 +- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index 92768419..f717e49b 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -64,8 +64,7 @@ export class App { #workspaces: WorkspaceManager; #documentsRoot: string | null = null; #recents: RecentDocuments | null = null; - #fontOpensInFlight = 0; - #recentsPublishDeferred = false; + /** Launchers a font open is replacing; they stop receiving recents so no half-ready card flashes. */ #replacedLaunchers = new WeakSet(); #pendingOpenPaths: string[] = []; #previewConversions = new Map>(); @@ -689,7 +688,7 @@ export class App { const sourcePath = this.#pendingOpenPaths[0]; try { - const session = await this.#openFontPath(sourcePath); + const session = await this.#workspaces.openPath(sourcePath); const opener = this.#windows.activeWindow() ?? this.#windows @@ -743,19 +742,30 @@ export class App { * @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.#openFontPath(sourcePath); + 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) { @@ -783,34 +793,8 @@ export class App { } } - /** - * Opens a font while holding back recents updates from renderer windows. - * - * @remarks - * Opening records the file before its window exists, so the launcher would - * briefly show a new card with no preview. Updates wait until every open has - * settled; by then a successful open has already replaced the launcher. - */ - async #openFontPath(sourcePath: string): Promise { - this.#fontOpensInFlight += 1; - try { - return await this.#workspaces.openPath(sourcePath); - } finally { - this.#fontOpensInFlight -= 1; - if (this.#fontOpensInFlight === 0 && this.#recentsPublishDeferred) { - this.#recentsPublishDeferred = false; - // After the caller has shown the font and closed the launcher it replaced. - setTimeout(() => this.#publishRecents(), 0); - } - } - } - #publishRecents(): void { this.#applicationMenu.refresh(); - if (this.#fontOpensInFlight > 0) { - this.#recentsPublishDeferred = true; - return; - } const documents = this.#recents?.list() ?? []; for (const window of this.#windows.allWindows()) { diff --git a/apps/desktop/src/main/recents/RecentDocuments.ts b/apps/desktop/src/main/recents/RecentDocuments.ts index 0492daeb..d415575e 100644 --- a/apps/desktop/src/main/recents/RecentDocuments.ts +++ b/apps/desktop/src/main/recents/RecentDocuments.ts @@ -10,7 +10,7 @@ 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 = 3; +const SPECIMEN_REVISION = 1; /** A built specimen and the file modification time it was built from. */ type Thumbnail = { From 6d82ebbff5d1b25e5139ff3f4006a41e560c588a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 11:20:12 +0100 Subject: [PATCH 10/11] feat: show compact list rows with the path on Alt List rows now show just the filename; holding Alt (Option) prefixes it with its folder, home-shortened to ~, so the path reads inline without cluttering every row. The launcher also takes the theme's text colour, so unstyled text such as Open Font follows dark palettes. --- apps/desktop/src/main/menu/menuItems.test.ts | 2 +- .../src/main/recents/RecentDocuments.test.ts | 12 ++++++-- .../src/main/recents/RecentDocuments.ts | 16 +++++++++- .../components/launcher/RecentFileItem.tsx | 29 ++++++++++++------- .../components/launcher/useRecentDocuments.ts | 23 +++++++++++++++ .../src/renderer/src/views/Landing.tsx | 2 +- apps/desktop/src/shared/recents.ts | 2 ++ 7 files changed, 71 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/menu/menuItems.test.ts b/apps/desktop/src/main/menu/menuItems.test.ts index 4d45bcce..e0aa4ebf 100644 --- a/apps/desktop/src/main/menu/menuItems.test.ts +++ b/apps/desktop/src/main/menu/menuItems.test.ts @@ -16,7 +16,7 @@ const ids = (items: MenuItemConstructorOptions[]) => items.flatMap(({ id }) => ( const noRecents = { documents: [], open: () => {}, clear: () => {} }; function recent(path: string, missing = false): RecentDocument { - return { path, documentId: null, openedAt: 0, missing }; + return { path, documentId: null, openedAt: 0, location: "~", missing, specimen: null }; } function submenu(item: MenuItemConstructorOptions): MenuItemConstructorOptions[] { diff --git a/apps/desktop/src/main/recents/RecentDocuments.test.ts b/apps/desktop/src/main/recents/RecentDocuments.test.ts index bfdcc365..8e6137f7 100644 --- a/apps/desktop/src/main/recents/RecentDocuments.test.ts +++ b/apps/desktop/src/main/recents/RecentDocuments.test.ts @@ -63,7 +63,7 @@ describe("RecentDocuments", () => { new RecentDocuments(storePath).record({ path: a, documentId: null }, 5); expect(new RecentDocuments(storePath).list()).toEqual([ - { path: a, documentId: null, openedAt: 5, missing: false, specimen: null }, + { path: a, documentId: null, openedAt: 5, location: root, missing: false, specimen: null }, ]); }); @@ -130,10 +130,18 @@ describe("RecentDocuments", () => { recents.record({ path: moved, documentId: "doc-1" }, 2); expect(recents.list()).toEqual([ - { path: moved, documentId: "doc-1", openedAt: 2, missing: false, specimen }, + { 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); diff --git a/apps/desktop/src/main/recents/RecentDocuments.ts b/apps/desktop/src/main/recents/RecentDocuments.ts index d415575e..45265f96 100644 --- a/apps/desktop/src/main/recents/RecentDocuments.ts +++ b/apps/desktop/src/main/recents/RecentDocuments.ts @@ -1,4 +1,5 @@ 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"; @@ -41,6 +42,7 @@ type RecentEntry = { */ export class RecentDocuments { readonly #filePath: string; + readonly #homeDirectory: string; readonly #listeners = new Set<() => void>(); #entries: RecentEntry[]; @@ -49,8 +51,9 @@ export class RecentDocuments { * * @param filePath - JSON file that stores the list across launches. */ - constructor(filePath: string) { + constructor(filePath: string, homeDirectory: string = os.homedir()) { this.#filePath = filePath; + this.#homeDirectory = homeDirectory; this.#entries = readEntries(filePath); } @@ -66,6 +69,7 @@ export class RecentDocuments { path: entry.path, documentId: entry.documentId, openedAt: entry.openedAt, + location: displayLocation(entry.path, this.#homeDirectory), missing: modifiedAt === null, specimen: currentThumbnail(entry, modifiedAt)?.specimen ?? null, }; @@ -192,6 +196,16 @@ function sameDocument(entry: RecentEntry, visit: RecentDocumentVisit): boolean { 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; diff --git a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx index f9ea56ce..3d1a462d 100644 --- a/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx +++ b/apps/desktop/src/renderer/src/components/launcher/RecentFileItem.tsx @@ -2,6 +2,7 @@ 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"; @@ -200,9 +201,23 @@ export const RecentFileCard = ({ document, folder, now, actions }: RecentFileIte ); }; -/** List row for one recent file: specimen chip, name, full path, and when it was opened. */ -export const RecentFileRow = ({ document, folder, now, actions }: RecentFileItemProps) => { - const name = pathBasename(document.path); +/** 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 ( @@ -226,13 +241,7 @@ export const RecentFileRow = ({ document, folder, now, actions }: RecentFileItem > - - - {name} - {folder && · {folder}} - - {document.path} - + {openedLine(document, undefined, now)} diff --git a/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts b/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts index 4d627565..f2482b2f 100644 --- a/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts +++ b/apps/desktop/src/renderer/src/components/launcher/useRecentDocuments.ts @@ -53,3 +53,26 @@ export function useMinuteClock(): number { 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/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index 651e1432..08064490 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -21,7 +21,7 @@ export const Landing = () => { }, [recentsLoaded]); return ( -
    +
    {/* Overlaid so the launcher centres against the whole window, not the space below the bar. */}
    diff --git a/apps/desktop/src/shared/recents.ts b/apps/desktop/src/shared/recents.ts index 501707f0..728c17a4 100644 --- a/apps/desktop/src/shared/recents.ts +++ b/apps/desktop/src/shared/recents.ts @@ -8,6 +8,8 @@ export type RecentDocument = { 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; /** From 05c8dae87a24d5238d854dc24229e62e05ff5b27 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:42:32 +0000 Subject: [PATCH 11/11] test(e2e): update visual snapshots --- .../landing.spec.ts/landing-default.png | Bin 11589 -> 11552 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 03b91a7eb0e67a64736d409a49f6dc4752a8612d..bf13d92f646b516921494a73a6280c201326b737 100644 GIT binary patch literal 11552 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 wp-Cu5dQDJ3q^tBA0TJoFNJ*p! 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