diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 90cef955e..c4033afb6 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -12,8 +12,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -24,7 +23,5 @@ jobs: sudo apt-get update sudo apt-get install -y xvfb libx11-dev pkg-config libpipewire-0.3-dev libspa-0.2-dev - run: pnpm install - - name: Magnifier Test - run: pnpm test:magnifier - name: Electron Test - run: xvfb-run --auto-servernum pnpm test:electron \ No newline at end of file + run: xvfb-run --auto-servernum pnpm test:electron diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index bb489e171..ec5ac86ad 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -12,12 +12,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: 'pnpm' - - run: pnpm install + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libx11-dev pkg-config libpipewire-0.3-dev libspa-0.2-dev + - run: pnpm install - name: Ember Test run: pnpm test:ember \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 74d2983df..780620262 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,12 +12,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: 'pnpm' - - run: pnpm install + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libx11-dev pkg-config libpipewire-0.3-dev libspa-0.2-dev + - run: pnpm install - name: Lint run: pnpm lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e589a14e3..c5ad3caf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,19 +11,21 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: 'pnpm' + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y xvfb libx11-dev pkg-config libpipewire-0.3-dev libspa-0.2-dev - run: pnpm install - name: Lint JS run: pnpm lint:js - name: Lint HBS run: pnpm lint:hbs - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y xvfb libpipewire-0.3-dev libspa-0.2-dev libx11-dev pkg-config - name: Browser Test run: pnpm test:ember - name: Electron Test @@ -38,16 +40,18 @@ jobs: matrix: os: [macos-15-intel, ubuntu-24.04, ubuntu-24.04-arm, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: 'pnpm' - - run: pnpm install - name: Install Linux dependencies if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y libpipewire-0.3-dev libx11-dev + run: | + sudo apt-get update + sudo apt-get install -y libx11-dev pkg-config libpipewire-0.3-dev libspa-0.2-dev + - run: pnpm install - name: Add macOS certs if: startsWith(matrix.os, 'macos') && startsWith(github.ref, 'refs/tags/') run: chmod +x add-osx-cert.sh && ./add-osx-cert.sh diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml deleted file mode 100644 index 4fa494d44..000000000 --- a/.github/workflows/rust-tests.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Rust Tests - -on: - push: - branches: - - main - pull_request: - paths: - - 'electron-app/magnifier/rust-sampler/**' - - '.github/workflows/rust-tests.yml' - -jobs: - test: - name: Test Rust Sampler - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install Linux dependencies - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libx11-dev libpipewire-0.3-dev pkg-config - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: 'pnpm' - - run: pnpm install - - # Run tests with platform-specific features - - name: Run Rust tests (Linux) - if: runner.os == 'Linux' - run: cd electron-app/magnifier/rust-sampler && cargo test --features x11,wayland - - - name: Run Rust tests (macOS/Windows) - if: runner.os != 'Linux' - run: cd electron-app/magnifier/rust-sampler && cargo test diff --git a/.prettierignore b/.prettierignore index cca0ce312..e7bc3dfaf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,7 +4,6 @@ # compiled output /dist/ /electron-app/dist/ -/electron-app/magnifier/rust-sampler/target/ # misc /coverage/ diff --git a/README.md b/README.md index aa01d9d24..ed24c7091 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ You will need the following things properly installed on your computer. - [pnpm](https://pnpm.io/) - [Ember CLI](https://ember-cli.com/) - [Google Chrome](https://google.com/chrome/) -- [Rust](https://www.rust-lang.org/) (for building the pixel sampler) +- [Rust](https://www.rust-lang.org/) (for building the hue-hunter color picker) ### Linux System Requirements @@ -46,7 +46,7 @@ sudo apt-get install scrot xdotool sudo apk add libxcb libxrandr dbus grim ``` -**Wayland Users:** The `grim` tool is required for screenshot-based color picking on Wayland. See [rust-sampler/README.md](rust-sampler/README.md) for more details. +**Wayland Users:** The color picker uses PipeWire + XDG Portal on Wayland (no external tools needed). See the [hue-hunter README](https://github.com/RobbieTheWagner/hue-hunter) for more details. ## Installation diff --git a/WARP.md b/WARP.md index c6cec0462..f7b63fbde 100644 --- a/WARP.md +++ b/WARP.md @@ -4,23 +4,19 @@ This file provides guidance to WARP (warp.dev) when working with code in this re ## About Swach -Swach is a modern color palette manager built as a menubar/system tray Electron app with an Ember.js frontend. It features color picking with a pixel-perfect magnifier, palette management, contrast checking, and cloud sync. +Swach is a modern color palette manager built as a menubar/system tray Electron app with an Ember.js frontend. It features color picking with a pixel-perfect magnifier (powered by the hue-hunter package), palette management, contrast checking, and cloud sync. ## Common Commands ### Development - `pnpm start` - Start Ember web app only (dev server at http://localhost:4200) -- `pnpm start:electron` - Start Electron app with hot-reload (builds Rust sampler in dev mode) -- `pnpm build:rust:dev` - Build Rust pixel sampler for development (with x11 and wayland features) +- `pnpm start:electron` - Start Electron app with hot-reload ### Testing - `ember test` - Run Ember tests once - `ember test --server` - Run Ember tests in watch mode - `pnpm test:ember` - Build and run Ember tests in CI mode -- `pnpm test:electron` - Build, package, and run Electron tests (includes Rust build for CI with x11 only) -- `pnpm test:magnifier` - Run magnifier unit tests (vitest) -- `pnpm test:magnifier:watch` - Run magnifier tests in watch mode -- `pnpm test:rust` - Run Rust sampler tests +- `pnpm test:electron` - Build, package, and run Electron tests ### Linting & Formatting - `pnpm lint` - Run all linters (JS, CSS, templates, types) @@ -28,8 +24,6 @@ Swach is a modern color palette manager built as a menubar/system tray Electron - `pnpm format` - Format code with Prettier ### Building & Packaging -- `pnpm build:rust` - Build production Rust sampler (with x11 and wayland features) -- `pnpm build:rust:ci` - Build Rust sampler for CI (x11 only, no wayland) - `pnpm package` - Package Electron app for current platform (creates app bundle) - `pnpm make` - Create distributable packages (DMG, deb, etc.) for current platform @@ -44,7 +38,7 @@ Swach is a modern color palette manager built as a menubar/system tray Electron - **Desktop**: Electron with Electron Forge (menubar app using `menubar` package) - **Data Layer**: Orbit.js (client-side ORM with sync strategies) - **Storage**: IndexedDB (local), AWS Cognito + API Gateway (cloud sync) -- **Color Picker**: Custom Rust binary for cross-platform pixel sampling +- **Color Picker**: hue-hunter package (cross-platform magnifying color picker with Rust-powered pixel sampling) ### Ember + Electron Integration @@ -72,14 +66,15 @@ Configuration logic is in `config/environment.js`. 2. **Production** (`pnpm package` or `pnpm make`): - Sets `EMBER_CLI_ELECTRON=true` environment variable + - Builds hue-hunter Rust sampler binary - Ember renderer built by Embroider + Vite - Electron main process built (`electron-app/main.ts`) - - Preload scripts built (`electron-app/src/preload.ts`, `electron-app/magnifier/magnifier-preload.ts`) - - Rust sampler binary included as `extraResource` in forge config + - Preload scripts built (`electron-app/src/preload.ts`) + - hue-hunter sampler binary included as `extraResource` in forge config - Everything bundled into ASAR archive Forge config: `forge.config.ts` -Vite configs: `vite.renderer.config.ts`, `vite.main.config.ts`, `vite.preload.config.ts`, `vite.magnifier.config.ts` +Vite configs: `vite.renderer.config.ts`, `vite.main.config.ts`, `vite.preload.config.ts` ### Data Architecture (Orbit.js) @@ -125,17 +120,16 @@ Coordinate data flow between sources. Key strategies: - Settings management (dock icon, auto-start) - Export/import functionality -**Magnifier/Color Picker**: -- Separate BrowserWindow overlaid on screen -- Rust binary (`electron-app/magnifier/rust-sampler/`) for pixel sampling -- Communicates via stdin/stdout JSON protocol +**Color Picker**: +- Uses the `hue-hunter` npm package (https://github.com/RobbieTheWagner/hue-hunter) +- Provides magnifying glass interface with Rust-powered pixel sampling - Platform-specific implementations: - macOS: Core Graphics APIs - Linux X11: XGetImage/XGetPixel (native, no deps) - - Linux Wayland: XDG Portal + PipeWire (persistent token) + - Linux Wayland: XDG Portal + PipeWire (persistent token saved to `~/.local/share/hue-hunter/screencast-token`) - Windows: GDI GetPixel API -- Manager: `electron-app/magnifier/rust-sampler-manager.ts` -- See `electron-app/magnifier/rust-sampler/README.md` for details +- Integration: `electron-app/src/color-picker.ts` uses `ColorPicker` class from hue-hunter +- Color naming via `color-name-list` and `nearest-color` packages ### Authentication & Cloud Sync @@ -157,7 +151,7 @@ Coordinate data flow between sources. Key strategies: ### Component Structure **Key Components** (`app/components/`): -- Color picker/magnifier integration components +- Color picker integration (launches hue-hunter picker via IPC) - Palette management (create, edit, delete, reorder) - Color contrast checker - Settings panels (cloud, data management) @@ -186,31 +180,23 @@ Coordinate data flow between sources. Key strategies: - Test selectors via ember-test-selectors - Testem for test running (Chrome in CI, Electron for electron-specific tests) -**Magnifier Tests**: -- Vitest for TypeScript magnifier code (`electron-app/magnifier/`) -- Unit tests for grid calculation, pixel utils, rust manager - -**Rust Tests**: -- Cargo test for Rust sampler logic ## Important Notes ### EMBER_CLI_ELECTRON Environment Variable This is the critical flag that switches between web and Electron modes. Always set it to `true` when packaging/building for Electron. Already configured in `package.json` scripts for `package` and `make` commands. -### Rust Sampler Binary -- Must be built before packaging: `pnpm build:rust` -- Development builds: `pnpm build:rust:dev` (includes both x11 and wayland) -- CI builds: `pnpm build:rust:ci` (x11 only, for faster CI builds) -- Location in development: `electron-app/magnifier/rust-sampler/target/[debug|release]/swach-sampler[.exe]` -- Location in production: `/swach-sampler[.exe]` -- Bundled via `extraResource` in `forge.config.ts` +### hue-hunter Color Picker +- Built via `pnpm build:hue-hunter` (automatically called by `package` and `make` commands) +- Binary location: `node_modules/hue-hunter/rust-sampler/target/release/hue-hunter-sampler[.exe]` +- Bundled to production as `extraResource` in `forge.config.ts` +- See hue-hunter README for Rust sampler details: https://github.com/RobbieTheWagner/hue-hunter ### Linux Platform Considerations -- X11 and Wayland support via Rust sampler feature flags +- X11 and Wayland support via hue-hunter's Rust sampler - X11: Native direct capture (fast, no deps) -- Wayland: PipeWire + Portal (requires permission on first use, token saved to `~/.local/share/swach/screencast-token`) -- Build dependencies: `libx11-dev`, `libpipewire-0.3-dev` (see rust-sampler README) +- Wayland: PipeWire + Portal (requires permission on first use, token saved to `~/.local/share/hue-hunter/screencast-token`) +- Build dependencies: `libx11-dev`, `libpipewire-0.3-dev` (see hue-hunter README) - Runtime: `libxcb1`, `libxrandr2`, `libdbus-1-3` auto-installed with .deb package ### Code Signing & Notarization @@ -238,8 +224,8 @@ The `SCHEMA_VERSION` in `config/environment.js` can be incremented to trigger mi ## File Organization - `app/` - Ember application code (components, routes, services, models, styles) -- `electron-app/` - Electron main process, IPC handlers, magnifier implementation -- `electron-app/magnifier/rust-sampler/` - Rust binary for pixel sampling +- `electron-app/` - Electron main process, IPC handlers, color picker integration +- `electron-app/src/color-picker.ts` - Color picker integration using hue-hunter - `config/` - Ember environment configuration - `tests/` - Ember test suite - `public/` - Static assets (copied to build output) diff --git a/electron-app/magnifier/grid-calculation.test.ts b/electron-app/magnifier/grid-calculation.test.ts deleted file mode 100644 index 20d36ef24..000000000 --- a/electron-app/magnifier/grid-calculation.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - calculateActualSquareSize, - calculateGridSize, -} from './grid-calculation'; - -describe('grid-calculation', () => { - describe('calculateGridSize', () => { - it('should calculate correct grid size for exact divisions', () => { - expect(calculateGridSize(180, 20)).toBe(9); // 180/20 = 9 - expect(calculateGridSize(210, 30)).toBe(7); // 210/30 = 7 - expect(calculateGridSize(150, 30)).toBe(5); // 150/30 = 5 - }); - - it('should return odd number when result is even', () => { - expect(calculateGridSize(200, 20)).toBe(11); // 200/20 = 10 → 11 (round up) - expect(calculateGridSize(240, 20)).toBe(13); // 240/20 = 12 → 13 (round up) - expect(calculateGridSize(160, 20)).toBe(9); // 160/20 = 8 → 9 (round up) - }); - - it('should always return odd numbers', () => { - const testCases = [ - { diameter: 120, squareSize: 10 }, - { diameter: 180, squareSize: 15 }, - { diameter: 420, squareSize: 25 }, - { diameter: 300, squareSize: 18 }, - ]; - - testCases.forEach(({ diameter, squareSize }) => { - const result = calculateGridSize(diameter, squareSize); - expect(result % 2).toBe(1); - }); - }); - - it('should handle all standard diameter/square size combinations', () => { - // Test some common combinations - expect(calculateGridSize(120, 10)).toBe(13); // 120/10 = 12 → 13 (round up) - expect(calculateGridSize(120, 20)).toBe(7); // 120/20 = 6 → 7 (round up) - expect(calculateGridSize(120, 40)).toBe(3); // 120/40 = 3 - - expect(calculateGridSize(180, 10)).toBe(19); // 180/10 = 18 → 19 (round up) - expect(calculateGridSize(180, 20)).toBe(9); // 180/20 = 9 - expect(calculateGridSize(180, 40)).toBe(5); // 180/40 = 4.5 → 5 (round + up) - - expect(calculateGridSize(420, 10)).toBe(43); // 420/10 = 42 → 43 (round up) - expect(calculateGridSize(420, 20)).toBe(21); // 420/20 = 21 - expect(calculateGridSize(420, 40)).toBe(11); // 420/40 = 10.5 → 11 (round + up) - }); - - it('should handle minimum grid size of 3', () => { - // Very large squares relative to diameter - expect(calculateGridSize(100, 50)).toBe(3); // 100/50 = 2 → 3 (round up to odd) - expect(calculateGridSize(120, 60)).toBe(3); // 120/60 = 2 → 3 (round up to odd) - }); - }); - - describe('calculateActualSquareSize', () => { - it('should divide diameter evenly by grid size', () => { - expect(calculateActualSquareSize(180, 9)).toBe(20); - expect(calculateActualSquareSize(210, 7)).toBe(30); - expect(calculateActualSquareSize(420, 21)).toBe(20); - }); - - it('should handle non-exact divisions with decimals', () => { - // 182px circle with 9 squares = 20.222...px per square - expect(calculateActualSquareSize(182, 9)).toBeCloseTo(20.222, 2); - - // 420px circle with 19 squares = 22.105...px per square - expect(calculateActualSquareSize(420, 19)).toBeCloseTo(22.105, 2); - }); - - it('should always fill the circle exactly', () => { - const testCases = [ - { diameter: 120, gridSize: 5 }, - { diameter: 180, gridSize: 9 }, - { diameter: 420, gridSize: 21 }, - { diameter: 300, gridSize: 15 }, - ]; - - testCases.forEach(({ diameter, gridSize }) => { - const squareSize = calculateActualSquareSize(diameter, gridSize); - // Grid of squares should exactly equal diameter - expect(squareSize * gridSize).toBeCloseTo(diameter, 5); - }); - }); - }); - - describe('integration: diameter changes', () => { - it('should increase grid size when diameter increases (keeping square size constant)', () => { - const squareSize = 20; - - // Start with 180px diameter - const grid1 = calculateGridSize(180, squareSize); - expect(grid1).toBe(9); // 180/20 = 9 - - // Increase to 210px - const grid2 = calculateGridSize(210, squareSize); - expect(grid2).toBe(11); // 210/20 = 10.5 → 11 (round + up) - - // Increase to 240px - const grid3 = calculateGridSize(240, squareSize); - expect(grid3).toBe(13); // 240/20 = 12 → 13 (round up) - }); - }); - - describe('integration: square size changes', () => { - it('should change grid size when square size changes (keeping diameter constant)', () => { - const diameter = 180; - - // Start with 20px squares - const grid1 = calculateGridSize(diameter, 20); - expect(grid1).toBe(9); // 180/20 = 9 - - // Reduce to 15px squares (more squares should fit) - const grid2 = calculateGridSize(diameter, 15); - expect(grid2).toBe(13); // 180/15 = 12 → 13 (round up to odd) - - // Increase to 30px squares (fewer squares fit) - const grid3 = calculateGridSize(diameter, 30); - expect(grid3).toBe(7); // 180/30 = 6 → 7 (round up to odd) - }); - }); -}); diff --git a/electron-app/magnifier/grid-calculation.ts b/electron-app/magnifier/grid-calculation.ts deleted file mode 100644 index f0fbe1f32..000000000 --- a/electron-app/magnifier/grid-calculation.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Calculate how many squares fit in a circle of given diameter. - * - * @param diameter - The diameter of the magnifier circle in pixels - * @param squareSize - The size of each square in pixels - * @returns The number of squares that fit (always odd for a center pixel) - */ -export function calculateGridSize( - diameter: number, - squareSize: number -): number { - // How many squares fit in the diameter (round to get closest fit) - const squaresFit = Math.round(diameter / squareSize); - - // Make it odd so we have a center pixel - // If even, round up to next odd number (to fill the circle better) - return squaresFit % 2 === 0 ? squaresFit + 1 : squaresFit; -} - -/** - * Calculate the actual rendered size of each square to perfectly fill the circle. - * This handles rounding - e.g., if 9 squares of 20px = 180px but circle is 182px, - * we render each square as 182/9 = 20.22px to fill perfectly. - * - * @param diameter - The diameter of the magnifier circle in pixels - * @param gridSize - The number of squares in the grid - * @returns The actual pixel size each square should be rendered at - */ -export function calculateActualSquareSize( - diameter: number, - gridSize: number -): number { - return diameter / gridSize; -} diff --git a/electron-app/magnifier/index.html b/electron-app/magnifier/index.html deleted file mode 100644 index bd90ac1d4..000000000 --- a/electron-app/magnifier/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - Swach Magnifier - - -
-
-
-
-
- - Color - - : - #FFFFFF -
-
-
-
- - - - diff --git a/electron-app/magnifier/magnifier-main-rust.ts b/electron-app/magnifier/magnifier-main-rust.ts deleted file mode 100644 index 0220d7ff2..000000000 --- a/electron-app/magnifier/magnifier-main-rust.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { colornames } from 'color-name-list'; -import { BrowserWindow, globalShortcut, ipcMain, screen } from 'electron'; -import isDev from 'electron-is-dev'; -import { type Menubar } from 'menubar'; -import nearestColor from 'nearest-color'; - -import { calculateGridSize } from './grid-calculation.js'; -import { RustSamplerManager, type PixelData } from './rust-sampler-manager.js'; -import { adjustSquareSize, getNextDiameter } from './utils.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -class MagnifyingColorPicker { - private magnifierWindow: BrowserWindow | null = null; - private isActive = false; - private samplerManager: RustSamplerManager; - private magnifierDiameter = 180; - private squareSize = 20; - private gridSize = 9; - private nearestColorFn: ({ - r, - g, - b, - }: { - r: number; - g: number; - b: number; - }) => { name: string }; - - constructor() { - // Setup color name lookup function - const namedColors = colornames.reduce( - ( - o: { [key: string]: string }, - { name, hex }: { name: string; hex: string } - ) => Object.assign(o, { [name]: hex }), - {} - ); - this.nearestColorFn = nearestColor.from(namedColors); - this.samplerManager = new RustSamplerManager(); - } - - private getColorName(r: number, g: number, b: number): string { - const result = this.nearestColorFn({ r, g, b }); - return result.name; - } - - async pickColor(): Promise { - if (this.isActive) { - return null; - } - - this.isActive = true; - - try { - // Pre-start the sampler to trigger permission dialogs BEFORE showing magnifier - // This is critical on Wayland where the permission dialog needs to be clickable - await this.samplerManager.ensureStarted(this.gridSize, 15); - await this.createMagnifierWindow(); - return await this.startColorPicking(); - } catch (error) { - console.error('[Magnifying Color Picker] Error:', error); - return null; - } finally { - this.cleanup(); - } - } - - private async createMagnifierWindow(): Promise { - const cursorPos = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPos); - - this.magnifierWindow = new BrowserWindow({ - x: display.bounds.x, - y: display.bounds.y, - width: display.size.width, - height: display.size.height, - frame: false, - transparent: true, - alwaysOnTop: true, - skipTaskbar: true, - resizable: false, - focusable: true, - show: false, - hasShadow: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - preload: join(__dirname, 'magnifier-preload.js'), - }, - }); - - // Set to screen-saver level - this.magnifierWindow.setAlwaysOnTop(true, 'screen-saver'); - - // Prevent this window from being captured in screen recordings/screenshots - // macOS: Uses NSWindowSharingNone - works perfectly with CGWindowListCreateImage - // Windows: Uses WDA_EXCLUDEFROMCAPTURE (Windows 10 2004+) - should work - // Linux: Limited/no support depending on compositor - this.magnifierWindow.setContentProtection(true); - console.log(`[Magnifier] Set content protection on ${process.platform}`); - - if (isDev) { - await this.magnifierWindow.loadURL('http://localhost:5173/'); - } else { - const magnifierPath = join( - __dirname, - '../renderer/magnifier_window/index.html' - ); - await this.magnifierWindow.loadFile(magnifierPath); - } - - this.magnifierWindow.show(); - } - - private startColorPicking(): Promise { - return new Promise((resolve) => { - let currentColor = '#FFFFFF'; - let hasResolved = false; - - const cleanup = () => { - // Unregister global shortcut - globalShortcut.unregister('Escape'); - - // Remove IPC listeners - ipcMain.removeAllListeners('color-selected'); - ipcMain.removeAllListeners('picker-cancelled'); - ipcMain.removeAllListeners('magnifier-zoom-diameter'); - ipcMain.removeAllListeners('magnifier-zoom-density'); - - // Remove window close handler - if (this.magnifierWindow && !this.magnifierWindow.isDestroyed()) { - this.magnifierWindow.removeAllListeners('closed'); - } - }; - - const resolveOnce = (result: string | null) => { - if (!hasResolved) { - hasResolved = true; - cleanup(); - resolve(result); - } - }; - - // Handle window close (Alt+F4, close button, etc.) - if (this.magnifierWindow && !this.magnifierWindow.isDestroyed()) { - this.magnifierWindow.once('closed', () => { - console.log('[Magnifier] Window closed externally'); - resolveOnce(null); - }); - } - - ipcMain.once('color-selected', () => resolveOnce(currentColor)); - ipcMain.once('picker-cancelled', () => resolveOnce(null)); - - ipcMain.on('magnifier-zoom-diameter', (_event, delta: number) => { - const newDiameter = getNextDiameter(this.magnifierDiameter, delta); - - if (newDiameter !== this.magnifierDiameter) { - this.magnifierDiameter = newDiameter; - this.gridSize = calculateGridSize( - this.magnifierDiameter, - this.squareSize - ); - - // Update grid size in Rust sampler - this.samplerManager.updateGridSize(this.gridSize); - } - }); - - ipcMain.on('magnifier-zoom-density', (_event, delta: number) => { - const newSquareSize = adjustSquareSize(this.squareSize, delta); - - if (newSquareSize !== this.squareSize) { - this.squareSize = newSquareSize; - this.gridSize = calculateGridSize( - this.magnifierDiameter, - this.squareSize - ); - - // Update grid size in Rust sampler - this.samplerManager.updateGridSize(this.gridSize); - } - }); - - globalShortcut.register('Escape', () => resolveOnce(null)); - - // Set up data callback for the sampler - const dataCallback = (pixelData: PixelData) => { - // Update current color - currentColor = pixelData.center.hex; - - // Get color name - const colorName = this.getColorName( - pixelData.center.r, - pixelData.center.g, - pixelData.center.b - ); - - // Update magnifier position - this.updateMagnifierPosition(pixelData.cursor); - - // Send pixel grid to renderer - if (this.magnifierWindow && !this.magnifierWindow.isDestroyed()) { - this.magnifierWindow.webContents.send('update-pixel-grid', { - centerColor: pixelData.center, - colorName, - pixels: pixelData.grid, - diameter: this.magnifierDiameter, - gridSize: this.gridSize, - squareSize: this.squareSize, - }); - } - }; - - const errorCallback = (error: string) => { - console.error('[Magnifying Color Picker] Sampler error:', error); - // Continue even on errors - they might be transient - }; - - // Start the Rust sampler if not already running - // (it may already be running from ensureStarted) - if (!this.samplerManager.isRunning()) { - console.log('[Magnifier] Starting sampler (not yet running)'); - this.samplerManager - .start( - this.gridSize, - 15, // 15 Hz sample rate (realistic for screen capture) - dataCallback, - errorCallback - ) - .catch((error: unknown) => { - console.error('[Magnifier] Failed to start sampler:', error); - }); - } else { - console.log( - '[Magnifier] Sampler already running from ensureStarted, updating callbacks' - ); - // Replace callbacks since ensureStarted used temporary ones - this.samplerManager.dataCallback = dataCallback; - this.samplerManager.errorCallback = errorCallback; - } - }); - } - - private updateMagnifierPosition(cursor: { x: number; y: number }): void { - if (!this.magnifierWindow || this.magnifierWindow.isDestroyed()) return; - - const windowBounds = this.magnifierWindow.getBounds(); - - this.magnifierWindow.webContents.send('update-magnifier-position', { - x: cursor.x, - y: cursor.y, - displayX: windowBounds.x, - displayY: windowBounds.y, - }); - } - - private cleanup(): void { - this.isActive = false; - - // Stop the Rust sampler - void this.samplerManager.stop(); - - if (this.magnifierWindow && !this.magnifierWindow.isDestroyed()) { - this.magnifierWindow.close(); - this.magnifierWindow = null; - } - - ipcMain.removeAllListeners('magnifier-ready'); - ipcMain.removeAllListeners('color-selected'); - ipcMain.removeAllListeners('picker-cancelled'); - ipcMain.removeAllListeners('magnifier-zoom-diameter'); - ipcMain.removeAllListeners('magnifier-zoom-density'); - - globalShortcut.unregister('Escape'); - } -} - -async function launchMagnifyingColorPicker( - mb: Menubar, - type = 'global' -): Promise { - const picker = new MagnifyingColorPicker(); - - try { - // Hide window and wait for it to be fully hidden - if (mb.window && !mb.window.isDestroyed()) { - const hidePromise = new Promise((resolve) => { - if (mb.window?.isVisible()) { - mb.window?.once('hide', () => resolve()); - mb.hideWindow(); - } else { - resolve(); - } - }); - await hidePromise; - } else { - mb.hideWindow(); - } - - const color = await picker.pickColor(); - - if (color) { - if (mb.window && !mb.window.isDestroyed()) { - if (type === 'global') { - mb.window.webContents.send('changeColor', color); - } - if (type === 'contrastBg') { - mb.window.webContents.send('pickContrastBgColor', color); - } - if (type === 'contrastFg') { - mb.window.webContents.send('pickContrastFgColor', color); - } - } - } - } finally { - void mb.showWindow(); - } -} - -export { launchMagnifyingColorPicker }; diff --git a/electron-app/magnifier/magnifier-main.ts b/electron-app/magnifier/magnifier-main.ts deleted file mode 100644 index ab1410104..000000000 --- a/electron-app/magnifier/magnifier-main.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { colornames } from 'color-name-list'; -import { - BrowserWindow, - desktopCapturer, - globalShortcut, - ipcMain, - screen, -} from 'electron'; -import isDev from 'electron-is-dev'; -import { type Menubar } from 'menubar'; -import nearestColor from 'nearest-color'; - -import { - adjustSquareSize, - calculateOptimalGridSize, - cursorToImageCoordinates, - getNextDiameter, -} from './utils'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -interface ColorInfo { - hex: string; - r: number; - g: number; - b: number; -} - -class MagnifyingColorPicker { - private magnifierWindow: BrowserWindow | null = null; - private updateInterval: NodeJS.Timeout | null = null; - private isActive = false; - private cachedScreenshot: { - bitmap: Buffer; - width: number; - height: number; - display: Electron.Display; - } | null = null; - private magnifierDiameter = 180; - private squareSize = 20; - private gridSize = 9; - private nearestColorFn: ({ - r, - g, - b, - }: { - r: number; - g: number; - b: number; - }) => { name: string }; - - constructor() { - // Setup color name lookup function - const namedColors = colornames.reduce( - ( - o: { [key: string]: string }, - { name, hex }: { name: string; hex: string } - ) => Object.assign(o, { [name]: hex }), - {} - ); - this.nearestColorFn = nearestColor.from(namedColors); - } - - private getColorName(r: number, g: number, b: number): string { - const result = this.nearestColorFn({ r, g, b }); - return result.name; - } - - async pickColor(): Promise { - if (this.isActive) { - return null; - } - - this.isActive = true; - - try { - await this.captureInitialScreenshot(); - await this.createMagnifierWindow(); - return await this.startColorPicking(); - } catch (error) { - console.error('[Magnifying Color Picker] Error:', error); - return null; - } finally { - this.cleanup(); - } - } - - private async captureInitialScreenshot(): Promise { - const cursorPos = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPos); - - const sources = await desktopCapturer.getSources({ - types: ['screen'], - thumbnailSize: { - width: display.size.width * display.scaleFactor, - height: display.size.height * display.scaleFactor, - }, - }); - - // Find the source that matches the display under the cursor - let source = sources.find((s) => s.display_id === display.id.toString()); - - // Fallback: if no matching display_id, just use the first source - if (!source && sources.length > 0) { - console.warn( - '[DEBUG] No matching display_id found, using first source as fallback' - ); - source = sources[0]; - } - - if (!source) { - console.error('[DEBUG] Failed to find any source!'); - console.error( - '[DEBUG] Available display_ids:', - sources.map((s) => s.display_id) - ); - console.error('[DEBUG] Needed display_id:', display.id.toString()); - throw new Error(`No screen source found for display ${display.id}`); - } - - const nativeImage = source.thumbnail; - const bitmap = nativeImage.toBitmap(); - - this.cachedScreenshot = { - bitmap, - width: nativeImage.getSize().width, - height: nativeImage.getSize().height, - display, - }; - } - - private async createMagnifierWindow(): Promise { - const cursorPos = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPos); - - this.magnifierWindow = new BrowserWindow({ - x: display.bounds.x, - y: display.bounds.y, - width: display.size.width, - height: display.size.height, - frame: false, - transparent: true, - alwaysOnTop: true, - skipTaskbar: true, - resizable: false, - focusable: true, - show: false, - hasShadow: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - preload: join(__dirname, 'magnifier-preload.js'), - }, - }); - - this.magnifierWindow.setAlwaysOnTop(true, 'screen-saver'); - - if (isDev) { - await this.magnifierWindow.loadURL('http://localhost:5173/'); - } else { - const magnifierPath = join( - __dirname, - '../renderer/magnifier_window/index.html' - ); - await this.magnifierWindow.loadFile(magnifierPath); - } - - this.magnifierWindow.show(); - } - - private async startColorPicking(): Promise { - return new Promise((resolve) => { - let currentColor = '#FFFFFF'; - let hasResolved = false; - - const resolveOnce = (result: string | null) => { - if (!hasResolved) { - hasResolved = true; - resolve(result); - } - }; - - ipcMain.once('color-selected', () => resolveOnce(currentColor)); - ipcMain.once('picker-cancelled', () => resolveOnce(null)); - - ipcMain.on('magnifier-zoom-diameter', (_event, delta: number) => { - const newDiameter = getNextDiameter(this.magnifierDiameter, delta); - - if (newDiameter !== this.magnifierDiameter) { - this.magnifierDiameter = newDiameter; - this.gridSize = calculateOptimalGridSize( - this.magnifierDiameter, - this.squareSize - ); - - const cursorPos = screen.getCursorScreenPoint(); - this.capturePixelGrid(cursorPos, (color: string) => { - currentColor = color; - }); - } - }); - - ipcMain.on('magnifier-zoom-density', (_event, delta: number) => { - const newSquareSize = adjustSquareSize(this.squareSize, delta); - - if (newSquareSize !== this.squareSize) { - this.squareSize = newSquareSize; - this.gridSize = calculateOptimalGridSize( - this.magnifierDiameter, - this.squareSize - ); - - const cursorPos = screen.getCursorScreenPoint(); - this.capturePixelGrid(cursorPos, (color: string) => { - currentColor = color; - }); - } - }); - - globalShortcut.register('Escape', () => resolveOnce(null)); - - this.startCursorTracking((color: string) => { - currentColor = color; - }); - }); - } - - private startCursorTracking(onColorChange: (color: string) => void): void { - let lastCursorPos = { x: -1, y: -1 }; - - this.updateInterval = setInterval(() => { - if (!this.magnifierWindow || this.magnifierWindow.isDestroyed()) { - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; - } - return; - } - - const cursorPos = screen.getCursorScreenPoint(); - - if (cursorPos.x !== lastCursorPos.x || cursorPos.y !== lastCursorPos.y) { - lastCursorPos = cursorPos; - this.updateMagnifierPosition(cursorPos); - this.capturePixelGrid(cursorPos, onColorChange); - } - }, 8); - } - - private updateMagnifierPosition(cursorPos: { x: number; y: number }): void { - if (!this.magnifierWindow || this.magnifierWindow.isDestroyed()) return; - - const windowBounds = this.magnifierWindow.getBounds(); - - this.magnifierWindow.webContents.send('update-magnifier-position', { - x: cursorPos.x, - y: cursorPos.y, - displayX: windowBounds.x, - displayY: windowBounds.y, - }); - } - - private capturePixelGrid( - cursorPos: { x: number; y: number }, - onColorChange: (color: string) => void - ): void { - if (!this.cachedScreenshot) return; - - const { bitmap, width, height, display } = this.cachedScreenshot; - - const { imageX, imageY } = cursorToImageCoordinates( - cursorPos.x, - cursorPos.y, - display.scaleFactor, - display.bounds - ); - - const getPixelAt = (x: number, y: number): ColorInfo | null => { - if (x < 0 || y < 0 || x >= width || y >= height) { - return null; - } - - const pixelIndex = (y * width + x) * 4; - if (pixelIndex + 3 >= bitmap.length) { - return null; - } - - const b = bitmap[pixelIndex] || 0; - const g = bitmap[pixelIndex + 1] || 0; - const r = bitmap[pixelIndex + 2] || 0; - - const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; - - return { hex, r, g, b }; - }; - - const centerColor = getPixelAt(imageX, imageY); - if (!centerColor) return; - - onColorChange(centerColor.hex); - - const gridSize = this.gridSize; - const halfSize = Math.floor(gridSize / 2); - const pixels: ColorInfo[][] = []; - - // Sample at logical pixel boundaries (scaleFactor apart) instead of physical pixels - // This ensures each cursor position maps to a unique grid position - const step = display.scaleFactor; - - for (let row = 0; row < gridSize; row++) { - pixels[row] = []; - for (let col = 0; col < gridSize; col++) { - const gridImageX = Math.floor(imageX - halfSize * step + col * step); - const gridImageY = Math.floor(imageY - halfSize * step + row * step); - - const pixelColor = getPixelAt(gridImageX, gridImageY); - pixels[row]![col] = pixelColor || { - hex: '#808080', - r: 128, - g: 128, - b: 128, - }; - } - } - - const colorName = this.getColorName( - centerColor.r, - centerColor.g, - centerColor.b - ); - - if (this.magnifierWindow && !this.magnifierWindow.isDestroyed()) { - this.magnifierWindow.webContents.send('update-pixel-grid', { - centerColor, - colorName, - pixels, - diameter: this.magnifierDiameter, - gridSize: this.gridSize, - }); - } - } - - private cleanup(): void { - this.isActive = false; - - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; - } - - if (this.magnifierWindow && !this.magnifierWindow.isDestroyed()) { - this.magnifierWindow.close(); - this.magnifierWindow = null; - } - - this.cachedScreenshot = null; - - ipcMain.removeAllListeners('magnifier-ready'); - ipcMain.removeAllListeners('color-selected'); - ipcMain.removeAllListeners('picker-cancelled'); - ipcMain.removeAllListeners('magnifier-zoom-diameter'); - ipcMain.removeAllListeners('magnifier-zoom-density'); - - globalShortcut.unregister('Escape'); - } -} - -async function launchMagnifyingColorPicker( - mb: Menubar, - type = 'global' -): Promise { - const picker = new MagnifyingColorPicker(); - - try { - // Hide window and wait for it to be fully hidden - if (mb.window && !mb.window.isDestroyed()) { - const hidePromise = new Promise((resolve) => { - if (mb.window?.isVisible()) { - mb.window?.once('hide', () => resolve()); - mb.hideWindow(); - } else { - resolve(); - } - }); - await hidePromise; - } else { - mb.hideWindow(); - } - - const color = await picker.pickColor(); - - if (color) { - if (type === 'global') { - mb.window?.webContents.send('changeColor', color); - } - if (type === 'contrastBg') { - mb.window?.webContents.send('pickContrastBgColor', color); - } - if (type === 'contrastFg') { - mb.window?.webContents.send('pickContrastFgColor', color); - } - } - } finally { - void mb.showWindow(); - } -} - -export { launchMagnifyingColorPicker }; diff --git a/electron-app/magnifier/magnifier-preload.ts b/electron-app/magnifier/magnifier-preload.ts deleted file mode 100644 index 047e8a1a1..000000000 --- a/electron-app/magnifier/magnifier-preload.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron'; - -// Define the actual API implementation - this is the source of truth for types -export const magnifierAPI = { - // Send methods for magnifier-specific events - send: { - ready: () => ipcRenderer.send('magnifier-ready'), - colorSelected: () => ipcRenderer.send('color-selected'), - cancelled: () => ipcRenderer.send('picker-cancelled'), - zoomDiameter: (delta: number) => - ipcRenderer.send('magnifier-zoom-diameter', delta), - zoomDensity: (delta: number) => - ipcRenderer.send('magnifier-zoom-density', delta), - }, - - // Receive methods for magnifier-specific updates - on: { - updatePosition: ( - callback: (data: { - x: number; - y: number; - displayX: number; - displayY: number; - }) => void - ) => { - const subscription = ( - _event: unknown, - data: { - x: number; - y: number; - displayX: number; - displayY: number; - } - ) => callback(data); - ipcRenderer.on('update-magnifier-position', subscription); - return subscription; - }, - updatePixelGrid: ( - callback: (data: { - centerColor: { hex: string; r: number; g: number; b: number }; - colorName: string; - pixels: Array>; - diameter: number; - gridSize: number; - squareSize: number; - }) => void - ) => { - const subscription = ( - _event: unknown, - data: { - centerColor: { hex: string; r: number; g: number; b: number }; - colorName: string; - pixels: Array< - Array<{ hex: string; r: number; g: number; b: number }> - >; - diameter: number; - gridSize: number; - squareSize: number; - } - ) => callback(data); - ipcRenderer.on('update-pixel-grid', subscription); - return subscription; - }, - }, -} as const; - -// Expose the API to the main world -contextBridge.exposeInMainWorld('magnifierAPI', magnifierAPI); diff --git a/electron-app/magnifier/main.ts b/electron-app/magnifier/main.ts deleted file mode 100644 index 27358f135..000000000 --- a/electron-app/magnifier/main.ts +++ /dev/null @@ -1,251 +0,0 @@ -import './styles.css'; - -import { - calculateActualSquareSize, - calculateGridSize, -} from './grid-calculation'; -import { calculatePixelUpdatesWithMismatch } from './pixel-grid-utils'; -import type { PixelGridData, PositionData } from './types'; - -class MagnifierRenderer { - private magnifierContainer: Element; - private magnifierCircle: Element; - private pixelGrid: Element; - private colorName: Element; - private hexCode: Element; - private currentDiameter = 180; // Default matches index.html - private currentSquareSize = 20; // Default square size - private lastRenderedGridSize = 9; // Track what's in the DOM - private lastDiameterZoomTime = 0; - private readonly DIAMETER_ZOOM_THROTTLE_MS = 300; - private hasShownCircle = false; - - // Derived state - calculated from diameter and square size - private get currentGridSize(): number { - return calculateGridSize(this.currentDiameter, this.currentSquareSize); - } - - constructor() { - const container = document.getElementById('magnifierContainer'); - const circle = document.querySelector('.magnifier-circle'); - const pixelGrid = document.getElementById('pixelGrid'); - const colorName = document.getElementById('colorName'); - const hexCode = document.getElementById('hexCode'); - - if (!container || !circle || !pixelGrid || !colorName || !hexCode) { - throw new Error('Required DOM elements not found for MagnifierRenderer'); - } - - this.magnifierContainer = container; - this.magnifierCircle = circle; - this.pixelGrid = pixelGrid; - this.colorName = colorName; - this.hexCode = hexCode; - - this.initialize(); - } - - private initialize(): void { - this.createPixelGrid(9); - this.setupEventListeners(); - this.setupIpcListeners(); - - // Signal that magnifier is ready - window.magnifierAPI.send.ready(); - - // Focus the window for keyboard events - window.focus(); - } - - private createPixelGrid(gridSize: number): void { - const totalPixels = gridSize * gridSize; - const centerIndex = Math.floor(totalPixels / 2); - const currentPixelCount = this.pixelGrid.children.length; - - // Calculate actual square size to fill the circle perfectly - const actualSquareSize = calculateActualSquareSize( - this.currentDiameter, - gridSize - ); - - const gridElement = this.pixelGrid as HTMLElement; - - // Update dimensions and grid template - // Don't override position/transform - let HTML/CSS handle centering - gridElement.style.width = `${this.currentDiameter}px`; - gridElement.style.height = `${this.currentDiameter}px`; - gridElement.style.gridTemplateColumns = `repeat(${gridSize}, ${actualSquareSize}px)`; - gridElement.style.gridTemplateRows = `repeat(${gridSize}, ${actualSquareSize}px)`; - - // Only rebuild if pixel count changed - if (currentPixelCount !== totalPixels) { - this.pixelGrid.innerHTML = ''; - - for (let i = 0; i < totalPixels; i++) { - const pixel = document.createElement('div'); - pixel.className = 'pixel'; - pixel.id = `pixel-${i}`; - - if (i === centerIndex) { - pixel.className += ' center'; - } - - this.pixelGrid.appendChild(pixel); - } - } else { - // Same number of pixels, just update center class - for (let i = 0; i < totalPixels; i++) { - const pixel = this.pixelGrid.children[i] as HTMLElement; - if (i === centerIndex) { - if (!pixel.classList.contains('center')) { - pixel.classList.add('center'); - } - } else { - pixel.classList.remove('center'); - } - } - } - - this.lastRenderedGridSize = gridSize; - } - - private updateMagnifierSize(diameter: number): void { - const circle = this.magnifierCircle as HTMLElement; - - // Set dimensions only - let CSS handle positioning - circle.style.width = `${diameter}px`; - circle.style.height = `${diameter}px`; - circle.style.minWidth = `${diameter}px`; - circle.style.minHeight = `${diameter}px`; - circle.style.maxWidth = `${diameter}px`; - circle.style.maxHeight = `${diameter}px`; - } - - private setupEventListeners(): void { - // Click to select color - document.addEventListener('click', () => { - window.magnifierAPI.send.colorSelected(); - }); - - // Escape to cancel - document.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') { - window.magnifierAPI.send.cancelled(); - } - }); - - // Scroll wheel for zooming - document.addEventListener( - 'wheel', - (e: WheelEvent) => { - e.preventDefault(); - - const delta = e.deltaY > 0 ? -1 : 1; - - if (e.altKey) { - // No throttling for density changes - keep them responsive - window.magnifierAPI.send.zoomDensity(delta); - } else { - // Throttle diameter changes to prevent rapid fire zooming - const now = Date.now(); - if ( - now - this.lastDiameterZoomTime < - this.DIAMETER_ZOOM_THROTTLE_MS - ) { - return; - } - this.lastDiameterZoomTime = now; - - window.magnifierAPI.send.zoomDiameter(delta); - } - }, - { passive: false } - ); - } - - private setupIpcListeners(): void { - // Listen for position updates - window.magnifierAPI.on.updatePosition((data: PositionData) => { - const displayX = data.displayX || 0; - const displayY = data.displayY || 0; - // Keep container offset fixed at 100px (half of initial 200px container) - // The circle will be centered within the container via flexbox - const offset = 100; - const translateX = data.x - displayX - offset; - const translateY = data.y - displayY - offset; - - (this.magnifierContainer as HTMLElement).style.transform = - `translate(${translateX}px, ${translateY}px)`; - - // Show circle on first position update (after it's correctly positioned) - if (!this.hasShownCircle) { - this.hasShownCircle = true; - this.magnifierCircle.classList.remove('opacity-0'); - } - }); - - // Listen for pixel grid updates - window.magnifierAPI.on.updatePixelGrid((data: PixelGridData) => { - const centerColor = data.centerColor; - const currentColorName = data.colorName || 'Unknown'; - - this.colorName.textContent = currentColorName; - this.hexCode.textContent = centerColor.hex.toUpperCase(); - - // Check what needs updating BEFORE modifying state - const needsSquareSizeUpdate = - data.squareSize && data.squareSize !== this.currentSquareSize; - const needsDiameterUpdate = - data.diameter && data.diameter !== this.currentDiameter; - - // Update state - if (needsSquareSizeUpdate) { - this.currentSquareSize = data.squareSize; - } - if (needsDiameterUpdate) { - this.currentDiameter = data.diameter; - } - - // Calculate grid size based on current diameter and square size - const calculatedGridSize = this.currentGridSize; - const needsGridUpdate = calculatedGridSize !== this.lastRenderedGridSize; - - // Update grid and circle together - if (needsGridUpdate) { - this.createPixelGrid(calculatedGridSize); - } - - if (needsDiameterUpdate) { - this.updateMagnifierSize(this.currentDiameter); - } - - // Update pixel colors using proper coordinate mapping - if (data.pixels && data.pixels.length > 0) { - const incomingDataSize = data.pixels.length; - - // Calculate which pixels need to be updated with proper coordinate mapping - // This handles size mismatches during transitions by centering smaller grids - const updates = calculatePixelUpdatesWithMismatch( - data.pixels, - incomingDataSize, - this.lastRenderedGridSize - ); - - // Apply all updates - for (const update of updates) { - const pixel = document.getElementById(update.pixelId); - if (pixel) { - pixel.style.backgroundColor = update.color.hex; - } - } - } - }); - } -} - -// Initialize the magnifier when the DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => new MagnifierRenderer()); -} else { - new MagnifierRenderer(); -} diff --git a/electron-app/magnifier/pixel-grid-utils.test.ts b/electron-app/magnifier/pixel-grid-utils.test.ts deleted file mode 100644 index f7f9095fa..000000000 --- a/electron-app/magnifier/pixel-grid-utils.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - calculatePixelUpdates, - calculatePixelUpdatesWithMismatch, - getCenterPixelId, - getPixelId, - type ColorData, -} from './pixel-grid-utils'; - -describe('pixel-grid-utils', () => { - describe('getPixelId', () => { - it('should calculate correct pixel IDs for a 3x3 grid', () => { - expect(getPixelId(0, 0, 3)).toBe('pixel-0'); - expect(getPixelId(0, 1, 3)).toBe('pixel-1'); - expect(getPixelId(0, 2, 3)).toBe('pixel-2'); - expect(getPixelId(1, 0, 3)).toBe('pixel-3'); - expect(getPixelId(1, 1, 3)).toBe('pixel-4'); // center - expect(getPixelId(2, 2, 3)).toBe('pixel-8'); - }); - - it('should calculate correct pixel IDs for a 5x5 grid', () => { - expect(getPixelId(0, 0, 5)).toBe('pixel-0'); - expect(getPixelId(2, 2, 5)).toBe('pixel-12'); // center - expect(getPixelId(4, 4, 5)).toBe('pixel-24'); - }); - }); - - describe('getCenterPixelId', () => { - it('should return center pixel ID for odd grid sizes', () => { - expect(getCenterPixelId(3)).toBe('pixel-4'); - expect(getCenterPixelId(5)).toBe('pixel-12'); - expect(getCenterPixelId(7)).toBe('pixel-24'); - expect(getCenterPixelId(9)).toBe('pixel-40'); - expect(getCenterPixelId(11)).toBe('pixel-60'); - }); - }); - - describe('calculatePixelUpdates', () => { - it('should map a 3x3 data grid correctly', () => { - const data: ColorData[][] = [ - [ - { hex: '#000000', r: 0, g: 0, b: 0 }, - { hex: '#111111', r: 17, g: 17, b: 17 }, - { hex: '#222222', r: 34, g: 34, b: 34 }, - ], - [ - { hex: '#333333', r: 51, g: 51, b: 51 }, - { hex: '#444444', r: 68, g: 68, b: 68 }, - { hex: '#555555', r: 85, g: 85, b: 85 }, - ], - [ - { hex: '#666666', r: 102, g: 102, b: 102 }, - { hex: '#777777', r: 119, g: 119, b: 119 }, - { hex: '#888888', r: 136, g: 136, b: 136 }, - ], - ]; - - const updates = calculatePixelUpdates(data, 3); - - expect(updates).toHaveLength(9); - expect(updates[0]).toEqual({ - pixelId: 'pixel-0', - color: { hex: '#000000', r: 0, g: 0, b: 0 }, - }); - expect(updates[4]).toEqual({ - pixelId: 'pixel-4', - color: { hex: '#444444', r: 68, g: 68, b: 68 }, - }); - expect(updates[8]).toEqual({ - pixelId: 'pixel-8', - color: { hex: '#888888', r: 136, g: 136, b: 136 }, - }); - }); - }); - - describe('calculatePixelUpdatesWithMismatch', () => { - it('should handle matching sizes by delegating to simple path', () => { - const data: ColorData[][] = [ - [ - { hex: '#000000', r: 0, g: 0, b: 0 }, - { hex: '#111111', r: 17, g: 17, b: 17 }, - ], - [ - { hex: '#222222', r: 34, g: 34, b: 34 }, - { hex: '#333333', r: 51, g: 51, b: 51 }, - ], - ]; - - const updates = calculatePixelUpdatesWithMismatch(data, 2, 2); - expect(updates).toHaveLength(4); - }); - - it('should center 3x3 data in a 5x5 display grid', () => { - // 3x3 data should be placed at positions [1,1] to [3,3] in a 5x5 grid - const data: ColorData[][] = [ - [ - { hex: '#000', r: 0, g: 0, b: 0 }, - { hex: '#111', r: 17, g: 17, b: 17 }, - { hex: '#222', r: 34, g: 34, b: 34 }, - ], - [ - { hex: '#333', r: 51, g: 51, b: 51 }, - { hex: '#444', r: 68, g: 68, b: 68 }, - { hex: '#555', r: 85, g: 85, b: 85 }, - ], - [ - { hex: '#666', r: 102, g: 102, b: 102 }, - { hex: '#777', r: 119, g: 119, b: 119 }, - { hex: '#888', r: 136, g: 136, b: 136 }, - ], - ]; - - const updates = calculatePixelUpdatesWithMismatch(data, 3, 5); - - // Should have 9 updates (3x3) - expect(updates).toHaveLength(9); - - // Top-left of data should map to pixel-6 (row 1, col 1 in 5x5 grid) - const topLeft = updates.find((u) => u.pixelId === 'pixel-6'); - expect(topLeft?.color.hex).toBe('#000'); - - // Center of data should map to pixel-12 (row 2, col 2 in 5x5 grid = center) - const center = updates.find((u) => u.pixelId === 'pixel-12'); - expect(center?.color.hex).toBe('#444'); - - // Bottom-right of data should map to pixel-18 (row 3, col 3 in 5x5 grid) - const bottomRight = updates.find((u) => u.pixelId === 'pixel-18'); - expect(bottomRight?.color.hex).toBe('#888'); - }); - - it('should use center portion of 5x5 data for a 3x3 display grid', () => { - // 5x5 data - only center 3x3 portion should be used - const data: ColorData[][] = [ - [ - { hex: '#00', r: 0, g: 0, b: 0 }, - { hex: '#01', r: 0, g: 1, b: 1 }, - { hex: '#02', r: 0, g: 2, b: 2 }, - { hex: '#03', r: 0, g: 3, b: 3 }, - { hex: '#04', r: 0, g: 4, b: 4 }, - ], - [ - { hex: '#10', r: 1, g: 0, b: 0 }, - { hex: '#11', r: 1, g: 1, b: 1 }, - { hex: '#12', r: 1, g: 2, b: 2 }, - { hex: '#13', r: 1, g: 3, b: 3 }, - { hex: '#14', r: 1, g: 4, b: 4 }, - ], - [ - { hex: '#20', r: 2, g: 0, b: 0 }, - { hex: '#21', r: 2, g: 1, b: 1 }, - { hex: '#22', r: 2, g: 2, b: 2 }, - { hex: '#23', r: 2, g: 3, b: 3 }, - { hex: '#24', r: 2, g: 4, b: 4 }, - ], - [ - { hex: '#30', r: 3, g: 0, b: 0 }, - { hex: '#31', r: 3, g: 1, b: 1 }, - { hex: '#32', r: 3, g: 2, b: 2 }, - { hex: '#33', r: 3, g: 3, b: 3 }, - { hex: '#34', r: 3, g: 4, b: 4 }, - ], - [ - { hex: '#40', r: 4, g: 0, b: 0 }, - { hex: '#41', r: 4, g: 1, b: 1 }, - { hex: '#42', r: 4, g: 2, b: 2 }, - { hex: '#43', r: 4, g: 3, b: 3 }, - { hex: '#44', r: 4, g: 4, b: 4 }, - ], - ]; - - const updates = calculatePixelUpdatesWithMismatch(data, 5, 3); - - // Should have 9 updates (3x3 display) - expect(updates).toHaveLength(9); - - // Top-left of display should use data[1][1] - const topLeft = updates.find((u) => u.pixelId === 'pixel-0'); - expect(topLeft?.color.hex).toBe('#11'); - - // Center of display should use data[2][2] - const center = updates.find((u) => u.pixelId === 'pixel-4'); - expect(center?.color.hex).toBe('#22'); - - // Bottom-right of display should use data[3][3] - const bottomRight = updates.find((u) => u.pixelId === 'pixel-8'); - expect(bottomRight?.color.hex).toBe('#33'); - }); - - it('should handle 9x9 data to 11x11 display', () => { - // Create 9x9 data - const data: ColorData[][] = Array.from({ length: 9 }, (_, row) => - Array.from({ length: 9 }, (_, col) => ({ - hex: `#${row}${col}`, - r: row * 10, - g: col * 10, - b: 0, - })) - ); - - const updates = calculatePixelUpdatesWithMismatch(data, 9, 11); - - // Should have 81 updates (9x9) - expect(updates).toHaveLength(81); - - // Top-left of data should map to pixel offset by 1 (11*1 + 1 = 12) - const topLeft = updates.find((u) => u.pixelId === 'pixel-12'); - expect(topLeft?.color.hex).toBe('#00'); - - // Center should map to center of 11x11 (pixel-60) - const center = updates.find((u) => u.pixelId === 'pixel-60'); - expect(center?.color.hex).toBe('#44'); - }); - - it('should handle 11x11 data to 11x11 display (no mismatch)', () => { - // Create 11x11 data - const data: ColorData[][] = Array.from({ length: 11 }, (_, row) => - Array.from({ length: 11 }, (_, col) => ({ - hex: `#r${row}c${col}`, - r: row, - g: col, - b: 0, - })) - ); - - const updates = calculatePixelUpdatesWithMismatch(data, 11, 11); - - // Should have 121 updates (11x11) - expect(updates).toHaveLength(121); - - // Top-left should be pixel-0 - const topLeft = updates.find((u) => u.pixelId === 'pixel-0'); - expect(topLeft?.color.hex).toBe('#r0c0'); - - // Center should be pixel-60 - const center = updates.find((u) => u.pixelId === 'pixel-60'); - expect(center?.color.hex).toBe('#r5c5'); - - // Bottom-right should be pixel-120 - const bottomRight = updates.find((u) => u.pixelId === 'pixel-120'); - expect(bottomRight?.color.hex).toBe('#r10c10'); - }); - - it('should handle 11x11 data to 9x9 display', () => { - // Create 11x11 data - const data: ColorData[][] = Array.from({ length: 11 }, (_, row) => - Array.from({ length: 11 }, (_, col) => ({ - hex: `#r${row}c${col}`, - r: row, - g: col, - b: 0, - })) - ); - - const updates = calculatePixelUpdatesWithMismatch(data, 11, 9); - - // Should have 81 updates (9x9 display) - expect(updates).toHaveLength(81); - - // Top-left of display should use data[1][1] (offset by 1) - const topLeft = updates.find((u) => u.pixelId === 'pixel-0'); - expect(topLeft?.color.hex).toBe('#r1c1'); - - // Center should use data[5][5] - const center = updates.find((u) => u.pixelId === 'pixel-40'); - expect(center?.color.hex).toBe('#r5c5'); - - // Bottom-right should use data[9][9] - const bottomRight = updates.find((u) => u.pixelId === 'pixel-80'); - expect(bottomRight?.color.hex).toBe('#r9c9'); - }); - }); -}); diff --git a/electron-app/magnifier/pixel-grid-utils.ts b/electron-app/magnifier/pixel-grid-utils.ts deleted file mode 100644 index 254424853..000000000 --- a/electron-app/magnifier/pixel-grid-utils.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Utility functions for managing pixel grid updates with proper index mapping. - */ - -export interface ColorData { - hex: string; - r: number; - g: number; - b: number; -} - -export interface PixelUpdate { - pixelId: string; - color: ColorData; -} - -/** - * Calculate the pixel element ID for a given row and column in a grid. - */ -export function getPixelId(row: number, col: number, gridSize: number): string { - const index = row * gridSize + col; - return `pixel-${index}`; -} - -/** - * Calculate all pixel updates needed when data size matches grid size. - * This is the simple case - direct 1:1 mapping. - */ -export function calculatePixelUpdates( - pixelData: ColorData[][], - gridSize: number -): PixelUpdate[] { - const updates: PixelUpdate[] = []; - - for (let row = 0; row < gridSize; row++) { - for (let col = 0; col < gridSize; col++) { - const colorData = pixelData[row]?.[col]; - if (colorData) { - updates.push({ - pixelId: getPixelId(row, col, gridSize), - color: colorData, - }); - } - } - } - - return updates; -} - -/** - * Calculate pixel updates when incoming data size doesn't match current grid size. - * Centers the smaller grid within the larger grid. - */ -export function calculatePixelUpdatesWithMismatch( - pixelData: ColorData[][], - dataSize: number, - displayGridSize: number -): PixelUpdate[] { - const updates: PixelUpdate[] = []; - - if (dataSize === displayGridSize) { - // No mismatch, use simple path - return calculatePixelUpdates(pixelData, displayGridSize); - } - - // Calculate offset to center the smaller grid - const offset = Math.floor(Math.abs(displayGridSize - dataSize) / 2); - - if (dataSize < displayGridSize) { - // Data is smaller than display grid - center it - for (let dataRow = 0; dataRow < dataSize; dataRow++) { - for (let dataCol = 0; dataCol < dataSize; dataCol++) { - const colorData = pixelData[dataRow]?.[dataCol]; - if (colorData) { - const displayRow = dataRow + offset; - const displayCol = dataCol + offset; - updates.push({ - pixelId: getPixelId(displayRow, displayCol, displayGridSize), - color: colorData, - }); - } - } - } - } else { - // Data is larger than display grid - use center portion - for (let displayRow = 0; displayRow < displayGridSize; displayRow++) { - for (let displayCol = 0; displayCol < displayGridSize; displayCol++) { - const dataRow = displayRow + offset; - const dataCol = displayCol + offset; - const colorData = pixelData[dataRow]?.[dataCol]; - if (colorData) { - updates.push({ - pixelId: getPixelId(displayRow, displayCol, displayGridSize), - color: colorData, - }); - } - } - } - } - - return updates; -} - -/** - * Get the center pixel ID for a given grid size. - */ -export function getCenterPixelId(gridSize: number): string { - const centerIndex = Math.floor((gridSize * gridSize) / 2); - return `pixel-${centerIndex}`; -} diff --git a/electron-app/magnifier/renderer-integration.test.ts b/electron-app/magnifier/renderer-integration.test.ts deleted file mode 100644 index 51d20951d..000000000 --- a/electron-app/magnifier/renderer-integration.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - calculatePixelUpdatesWithMismatch, - type ColorData, - type PixelUpdate, -} from './pixel-grid-utils'; - -/** - * Integration tests that simulate real-world scenarios of grid resizing - * and verify that all pixels get updated correctly. - */ -describe('Magnifier Renderer Integration', () => { - // Helper to create dummy color data - function createGridData(size: number): ColorData[][] { - return Array.from({ length: size }, (_, row) => - Array.from({ length: size }, (_, col) => ({ - hex: `#${row.toString(16)}${col.toString(16)}0`, - r: row * 10, - g: col * 10, - b: 0, - })) - ); - } - - // Helper to verify all pixels in a grid size are covered - function verifyAllPixelsCovered(updates: PixelUpdate[]): void { - const pixelIds = new Set(updates.map((u) => u.pixelId)); - - // Check that we have the right number of unique updates - expect(pixelIds.size).toBe(updates.length); - } - - describe('Zoom In Scenarios (Grid Growing)', () => { - it('should handle 9x9 → 11x11 transition correctly', () => { - // Simulate the transition when user scrolls to zoom in - const oldData = createGridData(9); - const newData = createGridData(11); - - // Frame 1: Display is 9x9, data is 9x9 (perfect match) - const frame1 = calculatePixelUpdatesWithMismatch(oldData, 9, 9); - expect(frame1).toHaveLength(81); - verifyAllPixelsCovered(frame1); - - // Frame 2: Display is 11x11, data is still 9x9 (transition) - // Should center 9x9 data in 11x11 grid - const frame2 = calculatePixelUpdatesWithMismatch(oldData, 9, 11); - expect(frame2).toHaveLength(81); // Only update the 81 pixels we have data for - verifyAllPixelsCovered(frame2); - - // Frame 3: Display is 11x11, data is 11x11 (complete) - const frame3 = calculatePixelUpdatesWithMismatch(newData, 11, 11); - expect(frame3).toHaveLength(121); - verifyAllPixelsCovered(frame3); - - // Verify that all 121 pixels would eventually be covered - const allPixelIds = new Set(frame3.map((u) => u.pixelId)); - expect(allPixelIds.size).toBe(121); - }); - - it('should handle 5x5 → 9x9 transition', () => { - const oldData = createGridData(5); - const newData = createGridData(9); - - // Frame 1: 5x5 data in 5x5 display - const frame1 = calculatePixelUpdatesWithMismatch(oldData, 5, 5); - expect(frame1).toHaveLength(25); - - // Frame 2: 5x5 data in 9x9 display (centered) - const frame2 = calculatePixelUpdatesWithMismatch(oldData, 5, 9); - expect(frame2).toHaveLength(25); - // Check that data is centered (offset by 2) - const frame2Ids = frame2.map((u) => u.pixelId); - expect(frame2Ids).toContain('pixel-20'); // Top-left of centered 5x5 - expect(frame2Ids).toContain('pixel-40'); // Center - expect(frame2Ids).toContain('pixel-60'); // Bottom-right of centered 5x5 - - // Frame 3: 9x9 data in 9x9 display - const frame3 = calculatePixelUpdatesWithMismatch(newData, 9, 9); - expect(frame3).toHaveLength(81); - }); - }); - - describe('Zoom Out Scenarios (Grid Shrinking)', () => { - it('should handle 11x11 → 9x9 transition correctly', () => { - const oldData = createGridData(11); - const newData = createGridData(9); - - // Frame 1: Display is 11x11, data is 11x11 - const frame1 = calculatePixelUpdatesWithMismatch(oldData, 11, 11); - expect(frame1).toHaveLength(121); - - // Frame 2: Display is 9x9, data is still 11x11 (transition) - // Should use center portion of 11x11 data - const frame2 = calculatePixelUpdatesWithMismatch(oldData, 11, 9); - expect(frame2).toHaveLength(81); - verifyAllPixelsCovered(frame2); - - // Frame 3: Display is 9x9, data is 9x9 - const frame3 = calculatePixelUpdatesWithMismatch(newData, 9, 9); - expect(frame3).toHaveLength(81); - }); - - it('should handle 9x9 → 5x5 transition', () => { - const oldData = createGridData(9); - const newData = createGridData(5); - - // Frame 1: 9x9 data in 9x9 display - const frame1 = calculatePixelUpdatesWithMismatch(oldData, 9, 9); - expect(frame1).toHaveLength(81); - - // Frame 2: 9x9 data in 5x5 display (use center) - const frame2 = calculatePixelUpdatesWithMismatch(oldData, 9, 5); - expect(frame2).toHaveLength(25); - verifyAllPixelsCovered(frame2); - - // Frame 3: 5x5 data in 5x5 display - const frame3 = calculatePixelUpdatesWithMismatch(newData, 5, 5); - expect(frame3).toHaveLength(25); - }); - }); - - describe('Density Changes (Alt+Scroll)', () => { - it('should handle keeping same grid size but different data', () => { - // When changing density, the display grid size changes but - // we might get old data size for a frame - const data1 = createGridData(9); - const data2 = createGridData(11); - - // Display stays 11x11, but data transitions 9→11 - const frame1 = calculatePixelUpdatesWithMismatch(data1, 9, 11); - expect(frame1).toHaveLength(81); - - const frame2 = calculatePixelUpdatesWithMismatch(data2, 11, 11); - expect(frame2).toHaveLength(121); - }); - }); - - describe('Large Grid Sizes', () => { - it('should handle maximum grid size of 21x21', () => { - const data = createGridData(21); - const updates = calculatePixelUpdatesWithMismatch(data, 21, 21); - - expect(updates).toHaveLength(441); // 21*21 - verifyAllPixelsCovered(updates); - }); - - it('should handle transition from 19x19 to 21x21', () => { - const oldData = createGridData(19); - const newData = createGridData(21); - - // Transition frame: 19x19 data in 21x21 display - const transitionFrame = calculatePixelUpdatesWithMismatch( - oldData, - 19, - 21 - ); - expect(transitionFrame).toHaveLength(361); // 19*19 - - // Final frame: 21x21 data in 21x21 display - const finalFrame = calculatePixelUpdatesWithMismatch(newData, 21, 21); - expect(finalFrame).toHaveLength(441); // 21*21 - }); - }); - - describe('Edge Cases', () => { - it('should handle minimum grid size of 5x5', () => { - const data = createGridData(5); - const updates = calculatePixelUpdatesWithMismatch(data, 5, 5); - - expect(updates).toHaveLength(25); - verifyAllPixelsCovered(updates); - }); - - it('should handle even-odd size transitions', () => { - // Even though we use odd grid sizes, test various combinations - const data7 = createGridData(7); - const data9 = createGridData(9); - - const updates = calculatePixelUpdatesWithMismatch(data7, 7, 9); - expect(updates).toHaveLength(49); - - const updates2 = calculatePixelUpdatesWithMismatch(data9, 9, 7); - expect(updates2).toHaveLength(49); - }); - }); - - describe('Pixel ID Consistency', () => { - it('should generate sequential pixel IDs without gaps', () => { - const data = createGridData(11); - const updates = calculatePixelUpdatesWithMismatch(data, 11, 11); - - // Extract all pixel numbers and sort them - const pixelNumbers = updates - .map((u) => parseInt(u.pixelId.replace('pixel-', ''))) - .sort((a, b) => a - b); - - // Should be 0-120 sequential - expect(pixelNumbers[0]).toBe(0); - expect(pixelNumbers[pixelNumbers.length - 1]).toBe(120); - expect(pixelNumbers).toHaveLength(121); - }); - - it('should use center pixels during transitions', () => { - const data = createGridData(9); - - // When centering 9x9 in 11x11, center pixel should be at index 60 - const updates = calculatePixelUpdatesWithMismatch(data, 9, 11); - - // Find the center pixel of the 9x9 data (index 4,4 in data) - const centerUpdate = updates.find( - (u) => u.color.hex === '#440' // row 4, col 4 - ); - - // Should map to center of 11x11 grid (pixel-60) - expect(centerUpdate?.pixelId).toBe('pixel-60'); - }); - }); - - describe('Color Data Preservation', () => { - it('should preserve exact color data during updates', () => { - const data: ColorData[][] = [ - [ - { hex: '#FF0000', r: 255, g: 0, b: 0 }, - { hex: '#00FF00', r: 0, g: 255, b: 0 }, - ], - [ - { hex: '#0000FF', r: 0, g: 0, b: 255 }, - { hex: '#FFFF00', r: 255, g: 255, b: 0 }, - ], - ]; - - const updates = calculatePixelUpdatesWithMismatch(data, 2, 2); - - // Verify colors are preserved exactly - expect(updates.find((u) => u.pixelId === 'pixel-0')?.color.hex).toBe( - '#FF0000' - ); - expect(updates.find((u) => u.pixelId === 'pixel-1')?.color.hex).toBe( - '#00FF00' - ); - expect(updates.find((u) => u.pixelId === 'pixel-2')?.color.hex).toBe( - '#0000FF' - ); - expect(updates.find((u) => u.pixelId === 'pixel-3')?.color.hex).toBe( - '#FFFF00' - ); - }); - }); -}); diff --git a/electron-app/magnifier/rust-sampler-manager.test.ts b/electron-app/magnifier/rust-sampler-manager.test.ts deleted file mode 100644 index 2a0c7d16c..000000000 --- a/electron-app/magnifier/rust-sampler-manager.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -import type { ChildProcessWithoutNullStreams } from 'child_process'; -import { EventEmitter } from 'events'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { RustSamplerManager, type PixelData } from './rust-sampler-manager'; - -// Mock child_process -vi.mock('child_process', () => ({ - spawn: vi.fn(), -})); - -// Mock electron modules -vi.mock('electron', () => ({ - app: { - getAppPath: () => '/mock/app/path', - }, -})); - -vi.mock('electron-is-dev', () => ({ - default: true, -})); - -interface MockWritable extends EventEmitter { - write: ReturnType; - end: ReturnType; - destroyed: boolean; -} - -class MockChildProcess extends EventEmitter { - stdin: MockWritable; - stdout: EventEmitter; - stderr: EventEmitter; - killed = false; - exitCode: number | null = null; - signalCode: string | null = null; - - constructor() { - super(); - const mockStdin = new EventEmitter() as MockWritable; - mockStdin.write = vi.fn(); - mockStdin.end = vi.fn(); - mockStdin.destroyed = false; - this.stdin = mockStdin; - - this.stdout = new EventEmitter(); - this.stderr = new EventEmitter(); - } - - kill(signal?: string) { - if (!this.killed) { - this.killed = true; - this.exitCode = null; - this.signalCode = signal || 'SIGTERM'; - setTimeout(() => { - this.emit('exit', null, this.signalCode); - this.emit('close', null, this.signalCode); - }, 10); - } - return true; - } -} - -describe('RustSamplerManager', () => { - let manager: RustSamplerManager; - let mockProcess: MockChildProcess; - let spawnMock: ReturnType; - - beforeEach(async () => { - manager = new RustSamplerManager(); - mockProcess = new MockChildProcess(); - - const childProcessModule = await import('child_process'); - spawnMock = vi.mocked(childProcessModule.spawn); - spawnMock.mockReturnValue( - mockProcess as unknown as ChildProcessWithoutNullStreams - ); - }); - - afterEach(async () => { - // Only stop if the process is actually running - // Some tests manually kill the process - if (manager.isRunning()) { - await manager.stop(); - } - vi.clearAllMocks(); - }); - - describe('start', () => { - it('should spawn the rust sampler process', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - void manager.start(9, 20, onData, onError); - - // Wait a bit for spawn to be called - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(spawnMock).toHaveBeenCalledWith( - expect.stringContaining('swach-sampler'), - [], - { stdio: ['pipe', 'pipe', 'pipe'] } - ); - - await manager.stop(); - }); - - it('should send start command after spawning', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - // Wait for command to be sent - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(mockProcess.stdin.write).toHaveBeenCalledWith( - expect.stringContaining('"command":"start"') - ); - expect(mockProcess.stdin.write).toHaveBeenCalledWith( - expect.stringContaining('"grid_size":9') - ); - expect(mockProcess.stdin.write).toHaveBeenCalledWith( - expect.stringContaining('"sample_rate":20') - ); - - await manager.stop(); - }); - - it('should call onData callback when valid pixel data is received', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - const pixelData: PixelData = { - cursor: { x: 100, y: 200 }, - center: { r: 255, g: 128, b: 64, hex: '#FF8040' }, - grid: [[{ r: 0, g: 0, b: 0, hex: '#000000' }]], - timestamp: 1234567890, - }; - - mockProcess.stdout.emit( - 'data', - Buffer.from(JSON.stringify(pixelData) + '\n') - ); - - // Wait for data to be processed - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onData).toHaveBeenCalledWith(pixelData); - expect(onError).not.toHaveBeenCalled(); - - await manager.stop(); - }); - - it('should call onError callback when error response is received', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - const errorResponse = { error: 'Test error message' }; - - mockProcess.stdout.emit( - 'data', - Buffer.from(JSON.stringify(errorResponse) + '\n') - ); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onError).toHaveBeenCalledWith('Test error message'); - expect(onData).not.toHaveBeenCalled(); - - await manager.stop(); - }); - - it('should handle incomplete JSON lines', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - const pixelData: PixelData = { - cursor: { x: 100, y: 200 }, - center: { r: 255, g: 128, b: 64, hex: '#FF8040' }, - grid: [[{ r: 0, g: 0, b: 0, hex: '#000000' }]], - timestamp: 1234567890, - }; - - const json = JSON.stringify(pixelData); - - // Send partial data - mockProcess.stdout.emit('data', Buffer.from(json.slice(0, 50))); - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Should not have called onData yet - expect(onData).not.toHaveBeenCalled(); - - // Send rest of data with newline - mockProcess.stdout.emit('data', Buffer.from(json.slice(50) + '\n')); - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Now should have called onData - expect(onData).toHaveBeenCalledWith(pixelData); - - await manager.stop(); - }); - - it('should handle malformed JSON gracefully', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - mockProcess.stdout.emit('data', Buffer.from('{"invalid json\n')); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Should not crash, just not call callbacks - expect(onData).not.toHaveBeenCalled(); - - await manager.stop(); - }); - - it('should handle process error events', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - const error = new Error('Process error'); - mockProcess.emit('error', error); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onError).toHaveBeenCalledWith('Process error'); - - await manager.stop(); - }); - - it('should stop existing process before starting new one', async () => { - const onData1 = vi.fn(); - const onError1 = vi.fn(); - const onData2 = vi.fn(); - const onError2 = vi.fn(); - - await manager.start(9, 20, onData1, onError1); - - const firstProcess = mockProcess; - const killSpy = vi.spyOn(firstProcess, 'kill'); - - // Create new mock process for second start - const newMockProcess = new MockChildProcess(); - spawnMock.mockReturnValue( - newMockProcess as unknown as ChildProcessWithoutNullStreams - ); - - await manager.start(11, 30, onData2, onError2); - - expect(killSpy).toHaveBeenCalled(); - }); - - it('should handle missing stdio streams', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - // Create process with null stdout - const brokenProcess = new MockChildProcess(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - brokenProcess.stdout = null as any; - spawnMock.mockReturnValue( - brokenProcess as unknown as ChildProcessWithoutNullStreams - ); - - await manager.start(9, 20, onData, onError); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(onError).toHaveBeenCalledWith( - expect.stringContaining('Failed to create process stdio streams') - ); - }); - }); - - describe('ensureStarted', () => { - it('should resolve when first data is received', async () => { - const promise = manager.ensureStarted(9, 20); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const pixelData: PixelData = { - cursor: { x: 100, y: 200 }, - center: { r: 255, g: 128, b: 64, hex: '#FF8040' }, - grid: [[{ r: 0, g: 0, b: 0, hex: '#000000' }]], - timestamp: 1234567890, - }; - - mockProcess.stdout.emit( - 'data', - Buffer.from(JSON.stringify(pixelData) + '\n') - ); - - await expect(promise).resolves.toBeUndefined(); - - await manager.stop(); - }); - - it('should reject on error', async () => { - const promise = manager.ensureStarted(9, 20); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const errorResponse = { error: 'Test error' }; - mockProcess.stdout.emit( - 'data', - Buffer.from(JSON.stringify(errorResponse) + '\n') - ); - - await expect(promise).rejects.toThrow('Test error'); - - await manager.stop(); - }); - - it('should timeout after 30 seconds', async () => { - vi.useFakeTimers(); - - const promise = manager.ensureStarted(9, 20); - - // Advance timers immediately - don't wait - vi.advanceTimersByTime(30001); - - await expect(promise).rejects.toThrow( - 'Timeout waiting for sampler to start' - ); - - vi.useRealTimers(); - }); - }); - - describe('updateGridSize', () => { - it('should send update_grid command', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - mockProcess.stdin.write.mockClear(); - - manager.updateGridSize(11); - - expect(mockProcess.stdin.write).toHaveBeenCalledWith( - expect.stringContaining('"command":"update_grid"') - ); - expect(mockProcess.stdin.write).toHaveBeenCalledWith( - expect.stringContaining('"grid_size":11') - ); - - await manager.stop(); - }); - - it('should not crash if called without active process', () => { - expect(() => manager.updateGridSize(11)).not.toThrow(); - }); - }); - - describe('stop', () => { - it('should send stop command and close stdin', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - mockProcess.stdin.write.mockClear(); - - const stopPromise = manager.stop(); - - expect(mockProcess.stdin.write).toHaveBeenCalledWith( - expect.stringContaining('"command":"stop"') - ); - expect(mockProcess.stdin.end).toHaveBeenCalled(); - - await stopPromise; - }); - - it('should force kill if process does not exit gracefully', async () => { - vi.useFakeTimers(); - - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - // Don't emit exit event to simulate hanging process - mockProcess.kill = vi.fn().mockReturnValue(true); - - const stopPromise = manager.stop(); - - // Advance time past the force kill timeout (500ms + buffer) - vi.advanceTimersByTime(700); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockProcess.kill).toHaveBeenCalled(); - - vi.useRealTimers(); - await stopPromise; - }); - - it('should resolve immediately if no process is running', async () => { - await expect(manager.stop()).resolves.toBeUndefined(); - }); - - it('should clear callbacks after stopping', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - await manager.stop(); - - expect(manager.dataCallback).toBeNull(); - expect(manager.errorCallback).toBeNull(); - }); - }); - - describe('isRunning', () => { - it('should return false initially', () => { - expect(manager.isRunning()).toBe(false); - }); - - it('should return true after starting', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - expect(manager.isRunning()).toBe(true); - - await manager.stop(); - }); - - it('should return false after stopping', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - await manager.stop(); - - expect(manager.isRunning()).toBe(false); - }); - - it('should return false if process is killed', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - // Simulate external kill - mockProcess.killed = true; - - expect(manager.isRunning()).toBe(false); - - // Let afterEach clean up - }); - }); - - describe('stderr handling', () => { - it('should log stderr data', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await manager.start(9, 20, onData, onError); - - mockProcess.stderr.emit('data', Buffer.from('Debug message\n')); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('RustSampler stderr'), - expect.any(String) - ); - - consoleErrorSpy.mockRestore(); - await manager.stop(); - }); - }); - - describe('process exit handling', () => { - it('should clean up on process exit', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - mockProcess.emit('exit', 0, null); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(manager.isRunning()).toBe(false); - }); - }); - - describe('multiple pixel data messages', () => { - it('should handle rapid data updates', async () => { - const onData = vi.fn(); - const onError = vi.fn(); - - await manager.start(9, 20, onData, onError); - - // Send 50 pixel data messages - for (let i = 0; i < 50; i++) { - const pixelData: PixelData = { - cursor: { x: i, y: i }, - center: { r: i % 256, g: i % 256, b: i % 256, hex: '#000000' }, - grid: [[{ r: 0, g: 0, b: 0, hex: '#000000' }]], - timestamp: Date.now() + i, - }; - - mockProcess.stdout.emit( - 'data', - Buffer.from(JSON.stringify(pixelData) + '\n') - ); - } - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(onData).toHaveBeenCalledTimes(50); - - await manager.stop(); - }); - }); -}); diff --git a/electron-app/magnifier/rust-sampler-manager.ts b/electron-app/magnifier/rust-sampler-manager.ts deleted file mode 100644 index 0c5bfebce..000000000 --- a/electron-app/magnifier/rust-sampler-manager.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { ChildProcess, spawn } from 'child_process'; -import { join } from 'path'; - -import { app } from 'electron'; -import isDev from 'electron-is-dev'; - -export interface PixelData { - cursor: { x: number; y: number }; - center: { r: number; g: number; b: number; hex: string }; - grid: Array>; - timestamp: number; -} - -interface ErrorResponse { - error: string; -} - -type SamplerResponse = PixelData | ErrorResponse; - -type SamplerCallback = (data: PixelData) => void; -type ErrorCallback = (error: string) => void; - -export class RustSamplerManager { - private process: ChildProcess | null = null; - public dataCallback: SamplerCallback | null = null; - public errorCallback: ErrorCallback | null = null; - private forceKillTimeout: NodeJS.Timeout | null = null; - - private getBinaryPath(): string { - if (isDev) { - // In development, use the debug build - const ext = process.platform === 'win32' ? '.exe' : ''; - return join( - app.getAppPath(), - 'electron-app', - 'magnifier', - 'rust-sampler', - 'target', - 'debug', - `swach-sampler${ext}` - ); - } else { - // In production, binary is in resources - const ext = process.platform === 'win32' ? '.exe' : ''; - const resourcesPath = process.resourcesPath; - return join(resourcesPath, `swach-sampler${ext}`); - } - } - - async start( - gridSize: number, - sampleRate: number, - onData: SamplerCallback, - onError: ErrorCallback - ): Promise { - if (this.process) { - console.warn('[RustSampler] Process already running, stopping first'); - await this.stop(); - } - - this.dataCallback = onData; - this.errorCallback = onError; - - const binaryPath = this.getBinaryPath(); - console.log('[RustSampler] Spawning process:', binaryPath); - - this.process = spawn(binaryPath, [], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - if (!this.process.stdout || !this.process.stdin || !this.process.stderr) { - const error = 'Failed to create process stdio streams'; - console.error('[RustSampler]', error); - - // Clean up the leaked process - const proc = this.process; - this.process = null; - - try { - if (proc && !proc.killed) { - proc.kill('SIGKILL'); - } - } catch (killError) { - console.error( - '[RustSampler] Failed to kill leaked process:', - killError - ); - } - - this.errorCallback?.(error); - return; - } - - // Set up data handlers - let buffer = ''; - this.process.stdout.on('data', (data: Buffer) => { - buffer += data.toString(); - - // Process complete JSON lines - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep incomplete line in buffer - - for (const line of lines) { - if (line.trim()) { - try { - const parsed = JSON.parse(line) as SamplerResponse; - - if ('error' in parsed) { - console.error('[RustSampler] Error:', parsed.error); - this.errorCallback?.(parsed.error); - } else { - this.dataCallback?.(parsed); - } - } catch (e) { - console.error('[RustSampler] Failed to parse JSON:', line, e); - } - } - } - }); - - this.process.stderr.on('data', (data: Buffer) => { - console.error('[RustSampler stderr]', data.toString()); - }); - - this.process.on('error', (error: Error) => { - console.error('[RustSampler] Process error:', error); - this.errorCallback?.(error.message); - }); - - this.process.on('exit', (code: number | null, signal: string | null) => { - console.log( - `[RustSampler] Process exited with code ${code}, signal ${signal}` - ); - this.process = null; - }); - - // Send start command - const startCommand = { - command: 'start', - grid_size: gridSize, - sample_rate: sampleRate, - }; - - this.sendCommand(startCommand); - } - - /** - * Ensure sampler is started and wait for first successful data callback - * This is useful for triggering permission dialogs before showing UI - */ - async ensureStarted(gridSize: number, sampleRate: number): Promise { - return new Promise((resolve, reject) => { - let resolved = false; - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - reject(new Error('Timeout waiting for sampler to start')); - } - }, 30000); // 30 second timeout for permission dialog - - // Start with a temporary callback that resolves on first data - void this.start( - gridSize, - sampleRate, - () => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - resolve(); - } - }, - (error) => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject(new Error(error)); - } - } - ); - }); - } - - updateGridSize(gridSize: number): void { - const command = { - command: 'update_grid', - grid_size: gridSize, - }; - this.sendCommand(command); - } - - stop(): Promise { - if (!this.process) { - return Promise.resolve(); - } - - const proc = this.process; - this.process = null; - this.dataCallback = null; - this.errorCallback = null; - - return new Promise((resolve) => { - // Set up exit handler - const onExit = () => { - if (this.forceKillTimeout) { - clearTimeout(this.forceKillTimeout); - this.forceKillTimeout = null; - } - resolve(); - }; - - proc.once('exit', onExit); - proc.once('close', onExit); - - // Send stop command - const stopCommand = { command: 'stop' }; - try { - if (proc.stdin && !proc.stdin.destroyed) { - const json = JSON.stringify(stopCommand); - proc.stdin.write(json + '\n'); - // Close stdin to signal EOF - proc.stdin.end(); - } - } catch (e) { - console.error('[RustSampler] Failed to send stop command:', e); - } - - // Give it a moment to clean up, then force kill if needed - this.forceKillTimeout = setTimeout(() => { - if (proc && !proc.killed) { - console.log('[RustSampler] Force killing process'); - proc.kill('SIGTERM'); - // Resolve after force kill - setTimeout(() => resolve(), 100); - } - }, 500); - }); - } - - private sendCommand(command: object): void { - if (!this.process) { - console.error('[RustSampler] Cannot send command, process is null'); - return; - } - - if (!this.process.stdin) { - console.error('[RustSampler] Cannot send command, process.stdin is null'); - console.error('[RustSampler] Process state:', { - killed: this.process.killed, - exitCode: this.process.exitCode, - signalCode: this.process.signalCode, - }); - return; - } - - try { - const json = JSON.stringify(command); - this.process.stdin.write(json + '\n'); - } catch (e) { - console.error('[RustSampler] Failed to send command:', e); - } - } - - isRunning(): boolean { - return this.process !== null && !this.process.killed; - } -} diff --git a/electron-app/magnifier/rust-sampler/.gitignore b/electron-app/magnifier/rust-sampler/.gitignore deleted file mode 100644 index 075bdcffd..000000000 --- a/electron-app/magnifier/rust-sampler/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -# Rust build artifacts -/target/ -Cargo.lock - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db diff --git a/electron-app/magnifier/rust-sampler/Cargo.toml b/electron-app/magnifier/rust-sampler/Cargo.toml deleted file mode 100644 index 447963327..000000000 --- a/electron-app/magnifier/rust-sampler/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -[package] -name = "swach-sampler" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "swach-sampler" -path = "src/main.rs" - -[lib] -name = "swach_sampler" -path = "src/lib.rs" - -[features] -# X11 support (requires system libraries: libx11-dev on Linux) -x11 = ["dep:x11", "dep:dirs"] -# Wayland support (requires system libraries: libpipewire-0.3-dev on Linux) -wayland = ["dep:ashpd", "dep:pipewire", "dep:tokio", "dep:futures"] -default = [] - -[dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -[target.'cfg(target_os = "macos")'.dependencies] -core-graphics = "0.23" -core-foundation = "0.9" -cocoa = "0.25" -objc = "0.2" - -[target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.58", features = [ - "Win32_Foundation", - "Win32_Graphics_Gdi", - "Win32_UI_WindowsAndMessaging", - "Win32_UI_HiDpi", -] } - -[target.'cfg(target_os = "linux")'.dependencies] -# X11 support for direct pixel sampling (optional, requires libx11-dev) -x11 = { version = "2.21", features = ["xlib"], optional = true } -dirs = { version = "5.0", optional = true } -# Optional dependencies for Wayland support (requires libpipewire-0.3-dev) -ashpd = { version = "0.9", optional = true } -pipewire = { version = "0.9", optional = true } -tokio = { version = "1", features = ["rt", "sync", "macros"], optional = true } -futures = { version = "0.3", optional = true } - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -strip = true diff --git a/electron-app/magnifier/rust-sampler/README.md b/electron-app/magnifier/rust-sampler/README.md deleted file mode 100644 index 4c009b056..000000000 --- a/electron-app/magnifier/rust-sampler/README.md +++ /dev/null @@ -1,223 +0,0 @@ -# Swach Pixel Sampler - -A high-performance, cross-platform pixel sampling tool written in Rust for the Swach color picker. - -## Overview - -This Rust binary provides continuous, real-time pixel sampling for the Swach color picker's magnifier feature. It communicates with the Electron main process via JSON over stdin/stdout. - -## Platform Support - -### macOS ✅ - -- Uses Core Graphics `CGDisplayCreateImage` for efficient screen capture -- Direct pixel access without full screenshots -- Hardware-accelerated sampling -- Native cursor position tracking via NSEvent - -### Linux (X11) ✅ - -- Uses native X11 `XGetImage` and `XGetPixel` APIs -- Direct pixel sampling without external tools -- Native cursor position tracking via `XQueryPointer` -- No external dependencies required -- Best performance - -### Linux (Wayland) ✅ **FULLY IMPLEMENTED** - -- Uses XDG Desktop Portal + PipeWire for screen capture -- **Persistent tokens** - Permission dialog only shown once, then saved to `~/.local/share/swach/screencast-token` -- Real-time video frame streaming via PipeWire -- Automatic video format detection (resolution, stride, pixel format) -- Supports GNOME, KDE Plasma, Sway, and other Portal-compatible compositors -- Performance: Excellent, ~15 FPS with low latency (comparable to X11) -- Note: Requires PipeWire 0.3+ and xdg-desktop-portal (standard on modern distros) - -### Windows ✅ - -- Uses Windows GDI `GetPixel` API -- Direct pixel sampling without screenshots -- Native cursor position tracking via `GetCursorPos` -- No external dependencies required - -## Linux Setup - -### Build Dependencies - -#### For All Linux Users (X11 + Wayland) - -```bash -# Ubuntu/Debian -sudo apt install build-essential pkg-config libx11-dev libpipewire-0.3-dev - -# Fedora -sudo dnf install gcc pkg-config libX11-devel pipewire-devel - -# Arch Linux -sudo pacman -S base-devel pkg-config libx11 pipewire -``` - -**Note:** PipeWire support is enabled by default. This allows the sampler to work on both X11 and Wayland systems seamlessly: - -- On X11: Uses direct X11 capture (fastest) -- On Wayland: Falls back to Portal + PipeWire (with persistent permissions) - -#### Optional: X11-Only Build - -If you only need X11 support (no Wayland): - -```bash -cargo build --no-default-features -``` - -The PipeWire dependencies are already included in the default build above, so Wayland support is automatically enabled. - -### Runtime Dependencies - -#### For X11 - -No additional runtime dependencies! X11 direct capture is used automatically. - -#### For Wayland - -PipeWire must be running (standard on modern Linux distros): - -```bash -# Check if PipeWire is running -systemctl --user status pipewire - -# Most modern distros (Ubuntu 22.04+, Fedora 34+, etc.) have PipeWire by default -``` - -**First-time Permission:** - -- On first use, you'll see a system dialog asking for screen capture permission -- Click "Share" to grant permission -- The permission token is saved in `~/.local/share/swach/screencast-token` -- Future launches will use the saved token automatically (no permission dialog) - -## Building - -### Development Build - -```bash -cargo build -``` - -The debug binary will be at `target/debug/swach-sampler` - -### Release Build - -```bash -cargo build --release -``` - -The optimized binary will be at `target/release/swach-sampler` - -## Communication Protocol - -### Input (Commands from Electron) - -Commands are sent as JSON over stdin, one per line: - -```json -{"command": "start", "grid_size": 9, "sample_rate": 60} -{"command": "update_grid", "grid_size": 11} -{"command": "stop"} -``` - -#### Start Command - -- `grid_size`: Number of pixels in each dimension (e.g., 9 = 9x9 grid) -- `sample_rate`: Target sampling frequency in Hz (e.g., 60 = 60 FPS) - -#### Update Grid Command - -- `grid_size`: New grid size to use - -#### Stop Command - -- No parameters, cleanly exits the sampler - -### Output (Pixel Data to Electron) - -Pixel data is sent as JSON over stdout, one object per sample: - -```json -{ - "cursor": {"x": 100, "y": 200}, - "center": {"r": 255, "g": 128, "b": 64, "hex": "#FF8040"}, - "grid": [ - [ - {"r": 255, "g": 128, "b": 64, "hex": "#FF8040"}, - {"r": 254, "g": 127, "b": 63, "hex": "#FE7F3F"}, - ... - ], - ... - ], - "timestamp": 1700000000000 -} -``` - -### Error Output - -Errors are sent as JSON to stdout: - -```json -{ - "error": "Failed to capture screen: permission denied" -} -``` - -Debug/diagnostic messages are sent to stderr. - -## Performance Notes - -### Sampling Rate - -- **macOS**: Can achieve 60+ FPS easily with direct pixel access -- **Windows**: Similar to macOS, native GDI is very fast -- **Linux (X11)**: Individual XGetImage calls per pixel - can achieve 30-60 FPS depending on grid size -- **Linux (Wayland)**: PipeWire video streaming - ~15 FPS with excellent quality and low latency - -### Grid Sampling - -Larger grid sizes (e.g., 15x15 vs 9x9) require more individual pixel samples, which impacts performance on all platforms. The impact is most noticeable on Linux/X11 where each pixel requires a separate X11 call. - -## Permissions - -### macOS - -- Requires "Screen Recording" permission in System Preferences → Security & Privacy → Screen Recording -- Electron app must be granted this permission - -### Linux (Wayland) - -- Uses XDG Desktop Portal screencast permission -- Permission dialog appears on first use (before magnifier shows) -- Permission token saved to `~/.local/share/swach/screencast-token` for future use -- To revoke: Delete the token file or revoke via desktop environment settings - -### Linux (X11) - -- No special permissions required -- Direct X11 access for pixel sampling and cursor position - -### Windows - -- No special permissions required -- Uses standard GDI APIs - -## Integration with Electron - -The `RustSamplerManager` class in `electron-app/src/rust-sampler-manager.ts` handles spawning and communicating with this binary. - -### Development Mode - -Binary location: `rust-sampler/target/debug/swach-sampler` - -### Production Mode - -Binary location: `/swach-sampler` (or `.exe` on Windows) - -The binary is automatically bundled with the Electron app during packaging. diff --git a/electron-app/magnifier/rust-sampler/WAYLAND_PIPEWIRE_STATUS.md b/electron-app/magnifier/rust-sampler/WAYLAND_PIPEWIRE_STATUS.md deleted file mode 100644 index d8606b313..000000000 --- a/electron-app/magnifier/rust-sampler/WAYLAND_PIPEWIRE_STATUS.md +++ /dev/null @@ -1,420 +0,0 @@ -# Wayland PipeWire Screen Capture - Screenshot-Based Implementation ✅ - -## Overview - -Wayland screen capture support using XDG Desktop Portal + PipeWire has been **fully implemented** using a **screenshot-based approach**. This document describes the implementation and its limitations. - -## Implementation Status: ✅ COMPLETE - -### ✅ All Features Implemented - -#### 1. XDG Desktop Portal Integration - -- **File**: `rust-sampler/src/sampler/wayland_portal.rs` -- **Status**: ✅ Fully implemented -- Successfully connects to the screencast portal -- Creates screencast sessions -- Selects monitor sources -- Starts screencast and obtains PipeWire node ID - -#### 2. Restore Token Persistence - -- **File**: `rust-sampler/src/sampler/wayland_portal.rs` (lines 108-127) -- **Status**: ✅ Fully implemented and tested -- Tokens are saved to: `~/.local/share/swach/screencast-token` -- Uses `PersistMode::ExplicitlyRevoked` for long-term persistence -- On subsequent app launches, the saved token is loaded and used -- **Result**: Permission dialog only shows once, then never again (until user explicitly revokes) - -#### 3. Screenshot-Based Sampling - -- **File**: `rust-sampler/src/sampler/wayland_portal.rs` -- **Status**: ✅ FULLY IMPLEMENTED -- Captures a screenshot on each sample/grid request -- Initializes PipeWire mainloop and context for each capture -- Creates PipeWire stream and connects to portal node -- Captures a single frame, then disconnects -- Parses video format metadata (width, height, stride) -- Extracts pixel data from SPA buffers -- **Result**: Screenshot-based sampling working! -- **Trade-off**: Not live-updating, but avoids capturing the magnifier window - -#### 4. Fallback Architecture - -- **Files**: - - `rust-sampler/src/sampler/mod.rs` (lines 35-68) - - `electron-app/src/color-picker.ts` -- **Status**: ✅ Fully implemented -- Tries X11 direct capture first (best performance) -- Falls back to Wayland Portal when X11 fails -- If Wayland Portal fails, falls back to Electron desktopCapturer (safety net) -- Graceful error handling at each layer - -#### 5. Lazy Permission Request - -- **Status**: ✅ Fully implemented -- Permission dialog appears BEFORE magnifier window shows -- Uses `ensure_screencast_started()` to request permission on first sample -- User can interact with permission dialog without cursor being hidden - -#### 6. Build System - -- **File**: `rust-sampler/Cargo.toml` -- **Status**: ✅ Complete -- Updated to pipewire 0.9 (latest version) -- PipeWire dependencies enabled by default on Linux -- Feature flag `wayland` for conditional compilation - -## Implementation Details - -### Why Screenshot-Based? - -**Wayland's security model** does not allow applications to exclude specific windows from screen captures. When using live PipeWire video streaming, the magnifier window overlay gets captured in the video feed, creating a circular dependency (the magnifier shows itself). - -Options we considered: - -1. **Live video streaming**: Captures magnifier window (doesn't work) -2. **Window exclusion**: Not supported by Wayland/PipeWire portals -3. **Layer shell protocol**: Only for compositor-specific overlays, not Electron windows -4. **setContentProtection**: Not widely supported on Linux compositors -5. **Screenshot approach**: ✅ Works - captures screen before magnifier appears - -### Key Components - -#### Screenshot Capture Flow - -1. User clicks to sample a pixel or requests a grid -2. `ensure_screencast_permission()` - Gets portal permission (once) -3. `capture_screenshot()` - Connects to PipeWire, captures one frame -4. Samples pixels from the screenshot buffer -5. Disconnects PipeWire stream - -#### Video Format Parsing - -```rust -match VideoInfoRaw::parse(param) { - Ok(info) => { - let width = info.size().width; - let height = info.size().height; - let stride = width as usize * 4; // BGRA - // Store for use in frame processing - } -} -``` - -#### Single Frame Capture - -```rust -// Capture until we get one frame -let frame_captured = Arc::new(AtomicBool::new(false)); -.process(move |stream, user_data| { - if frame_captured.load(SeqCst) { - return; // Already got our frame - } - // ... extract frame data ... - frame_captured.store(true, SeqCst); -}) -``` - -### Thread Safety - -- Screenshot buffer is shared via `Arc>>` -- Video format info shared via `Arc>>` -- Frame capture flag uses `Arc` for lock-free synchronization - -### Lifecycle Management - -- PipeWire resources created and destroyed per screenshot -- X11 display kept open and properly closed in `Drop` implementation -- No background threads - mainloop runs synchronously until frame captured - -## Testing the Implementation - -### Development Setup - -1. **Install required libraries** (Ubuntu/Debian): - -```bash -sudo apt install libpipewire-0.3-dev libx11-dev pkg-config libclang-dev -``` - -For Fedora/RHEL: - -```bash -sudo dnf install pipewire-devel libX11-devel pkg-config clang-devel -``` - -For Arch Linux: - -```bash -sudo pacman -S pipewire libx11 pkgconf clang -``` - -2. **Build the Rust sampler**: - -```bash -cd rust-sampler -cargo build --release -``` - -3. **Verify PipeWire is running**: - -```bash -systemctl --user status pipewire -``` - -If not running: - -```bash -systemctl --user start pipewire -``` - -### Testing Workflow - -#### First Run - -1. Click the eyedropper tool in Swach -2. **Permission dialog should appear** (before magnifier) -3. Grant screen capture permission -4. Token is saved to `~/.local/share/swach/screencast-token` -5. Magnifier appears with real-time screen content -6. Colors sample correctly from screen -7. Smooth updates at ~15 FPS - -#### Subsequent Runs - -1. Click the eyedropper tool -2. **NO permission dialog** (token loaded automatically) -3. Magnifier appears immediately -4. Colors sample correctly -5. No fallback to Electron needed - -#### Verify It's Working - -Check the console output for these messages: - -``` -═══════════════════════════════════════════════════════ - Initializing Wayland Screen Capture -═══════════════════════════════════════════════════════ - -Using saved screen capture permission... -✓ Screen capture started successfully -PipeWire node ID: 123 -Initializing PipeWire... -Connecting to PipeWire node 123... -✓ PipeWire stream connected successfully -PipeWire mainloop started -PipeWire stream state: ... -> Streaming -Video format: 1920x1080 stride=7680 -✓ Screen capture fully initialized -``` - -### Debugging - -#### Enable verbose logging: - -```bash -RUST_LOG=debug ./target/debug/swach-sampler -``` - -#### Check token file: - -```bash -cat ~/.local/share/swach/screencast-token -``` - -#### Test PipeWire connectivity: - -```bash -pw-cli ls Node -``` - -#### Monitor PipeWire activity: - -```bash -pw-top -``` - -### Known Limitations - -1. **Screenshot-based, not live**: Unlike macOS/Windows, the Wayland implementation captures a screenshot on each sample request rather than streaming live video. This is necessary to avoid capturing the magnifier window overlay, as Wayland's security model does not support excluding specific windows from screen captures. - -2. **First-time permission required**: Users must grant permission on first use (by design) - -3. **X11 dependency for cursor**: Still uses X11 `XQueryPointer` for cursor position (XWayland required) - -4. **Format assumption**: Assumes BGRA pixel format (standard for most compositors) - -5. **Single monitor**: Currently captures first stream only (usually primary monitor) - -6. **Performance**: Screenshot capture has more overhead than live streaming (~50-100ms per screenshot vs continuous frames) - -### Troubleshooting - -#### "Cannot find libraries: libpipewire-0.3" - -Install PipeWire development libraries (see Development Setup above) - -#### "Failed to connect to screencast portal" - -Ensure you're running a Wayland session with xdg-desktop-portal installed: - -```bash -sudo apt install xdg-desktop-portal xdg-desktop-portal-gtk -# or for GNOME: -sudo apt install xdg-desktop-portal-gnome -# or for KDE: -sudo apt install xdg-desktop-portal-kde -``` - -#### "No frames received" - -Check that PipeWire is running and the stream is active: - -```bash -systemctl --user status pipewire -pw-cli ls Node # Look for "screen capture" nodes -``` - -#### Gray pixels instead of screen content - -This indicates frames aren't being received. Check: - -1. PipeWire version is 0.3 or later -2. Portal implementation supports screencast -3. Console shows "Video format: WxH" message - -## Success Criteria - -All criteria have been met! ✅ - -### On First Use - -- [x] Click eyedropper tool -- [x] Permission dialog appears (before magnifier) -- [x] User can click dialog (cursor visible) -- [x] Grant permission -- [x] Token saved to `~/.local/share/swach/screencast-token` -- [x] Magnifier appears -- [x] Colors sample correctly from screen -- [x] Smooth ~15 FPS updates - -### On Subsequent Uses - -- [x] Click eyedropper tool -- [x] NO permission dialog (token loaded) -- [x] Magnifier appears immediately -- [x] Colors sample correctly -- [x] No Electron fallback needed - -### Performance - -- [x] Screenshot capture: ~50-100ms per sample/grid -- [x] Acceptable for color picking use case -- [x] Not suitable for real-time preview (by design - prevents magnifier capture) - -## System Compatibility - -### Tested Environments - -The implementation should work on: - -- **GNOME** (Wayland): ✅ Full support -- **KDE Plasma** (Wayland): ✅ Full support -- **Sway**: ✅ Full support -- **Weston**: ✅ Should work -- **Hyprland**: ✅ Should work - -### Requirements - -- Linux with Wayland compositor -- PipeWire 0.3 or later -- xdg-desktop-portal (and compositor-specific backend) -- XWayland (for cursor position tracking) - -## Integration with Electron App - -### Communication Flow - -1. **Electron** → Rust: `{ "command": "start", "grid_size": 9, "sample_rate": 15 }` -2. **Rust** → PipeWire: Request screen capture frames -3. **PipeWire** → Rust: Video frames (continuous) -4. **Rust** → Electron: Pixel samples (JSON) - -### Error Handling - -If PipeWire fails, the system gracefully falls back to Electron's desktopCapturer, ensuring the app always works. - -## File Structure - -``` -rust-sampler/src/sampler/ -├── mod.rs # Platform detection and sampler creation -├── linux.rs # X11 direct capture (working) -├── wayland_portal.rs # Wayland Portal + PipeWire (✅ COMPLETE) -├── macos.rs # macOS implementation (working) -└── windows.rs # Windows implementation (working) - -electron-app/src/ -├── color-picker.ts # High-level color picker logic with fallback -└── rust-sampler-manager.ts # Manages Rust binary process communication -``` - -## Technical Details - -### Dependencies - -```toml -pipewire = "0.9" # PipeWire Rust bindings (latest) -ashpd = "0.9" # XDG Desktop Portal client -tokio = "1" # Async runtime for portal communication -x11 = "2.21" # Cursor position tracking -dirs = "5.0" # Finding .local/share directory -``` - -### Pixel Format - -Frames are expected in **BGRA** format (B=byte 0, G=byte 1, R=byte 2, A=byte 3). This is converted to RGB for the app: - -```rust -let b = frame.data[offset]; -let g = frame.data[offset + 1]; -let r = frame.data[offset + 2]; -// Alpha channel ignored -``` - -### Memory Management - -- Frame buffer is allocated once and reused for each frame -- Old frame data is overwritten (no memory leaks) -- Mutexes ensure thread-safe access to shared data - -## Future Enhancements - -Potential improvements (not required for current implementation): - -1. **Native Wayland cursor tracking**: Remove X11 dependency using `zwp_pointer_constraints_v1` -2. **Multi-monitor support**: Allow user to select which monitor to sample -3. **Format flexibility**: Support RGB, RGBA, and other pixel formats -4. **Reconnection logic**: Automatically reconnect if PipeWire stream disconnects -5. **Performance metrics**: Track and display FPS, latency in debug mode - -## Credits - -This implementation was made possible by: - -- **PipeWire team**: For the excellent media framework -- **XDG Desktop Portal team**: For the portal specification -- **pipewire-rs maintainers**: For high-quality Rust bindings -- **ashpd team**: For the portal client library - -## License - -Same as the rest of the Swach project. - ---- - -**Status**: ✅ IMPLEMENTATION COMPLETE -**Last Updated**: November 27, 2024 -**Version**: pipewire 0.9, ashpd 0.9 diff --git a/electron-app/magnifier/rust-sampler/src/lib.rs b/electron-app/magnifier/rust-sampler/src/lib.rs deleted file mode 100644 index e89216d1f..000000000 --- a/electron-app/magnifier/rust-sampler/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Library exports for testing -pub mod types; diff --git a/electron-app/magnifier/rust-sampler/src/main.rs b/electron-app/magnifier/rust-sampler/src/main.rs deleted file mode 100644 index b2945080f..000000000 --- a/electron-app/magnifier/rust-sampler/src/main.rs +++ /dev/null @@ -1,225 +0,0 @@ -mod sampler; -mod types; - -use sampler::create_sampler; -use std::io::{self, BufRead, Write}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use types::{Color, ColorData, Command, ErrorResponse, PixelData, PixelSampler, Point}; - -fn main() { - if let Err(e) = run() { - eprintln!("Fatal error: {}", e); - let error = ErrorResponse { - error: e.to_string(), - }; - if let Ok(json) = serde_json::to_string(&error) { - println!("{}", json); - } - std::process::exit(1); - } -} - -fn run() -> Result<(), String> { - use std::sync::mpsc::{channel, Receiver}; - use std::thread; - - eprintln!("Swach pixel sampler starting..."); - - let mut sampler = create_sampler()?; - eprintln!("Sampler created successfully"); - - // Create channels for command communication - let (cmd_tx, cmd_rx): (std::sync::mpsc::Sender, Receiver) = channel(); - - // Spawn stdin reader thread - thread::spawn(move || { - let stdin = io::stdin(); - let mut reader = stdin.lock(); - let mut line = String::new(); - - loop { - line.clear(); - match reader.read_line(&mut line) { - Ok(0) => { - eprintln!("[StdinThread] EOF received"); - let _ = cmd_tx.send(Command::Stop); - break; - } - Ok(_) => { - let trimmed = line.trim(); - if !trimmed.is_empty() { - match serde_json::from_str::(trimmed) { - Ok(cmd) => { - let _ = cmd_tx.send(cmd); - } - Err(e) => { - eprintln!("[StdinThread] Failed to parse: {} - Error: {}", trimmed, e); - } - } - } - } - Err(e) => { - eprintln!("[StdinThread] Read error: {}", e); - let _ = cmd_tx.send(Command::Stop); - break; - } - } - } - }); - - // Main loop - wait for commands from channel - loop { - match cmd_rx.recv() { - Ok(Command::Start { grid_size, sample_rate }) => { - eprintln!("Starting sampling: grid_size={}, sample_rate={}", grid_size, sample_rate); - if let Err(e) = run_sampling_loop(&mut *sampler, grid_size, sample_rate, &cmd_rx) { - eprintln!("Sampling loop error: {}", e); - send_error(&e); - } - } - Ok(Command::UpdateGrid { .. }) => { - eprintln!("Update grid command received outside sampling loop (ignored)"); - } - Ok(Command::Stop) => { - eprintln!("Stop command received"); - break; - } - Err(e) => { - eprintln!("Channel error: {}", e); - break; - } - } - } - - eprintln!("Sampler exiting"); - Ok(()) -} - -fn run_sampling_loop( - sampler: &mut dyn PixelSampler, - initial_grid_size: usize, - sample_rate: u64, - cmd_rx: &std::sync::mpsc::Receiver, -) -> Result<(), String> { - use std::sync::mpsc::TryRecvError; - - let interval = Duration::from_micros(1_000_000 / sample_rate); - let mut last_cursor = Point { x: -1, y: -1 }; - let mut sample_count = 0; - let start_time = std::time::Instant::now(); - let mut slow_frame_count = 0; - let mut current_grid_size = initial_grid_size; - - loop { - // Check for commands (non-blocking) - match cmd_rx.try_recv() { - Ok(Command::UpdateGrid { grid_size }) => { - current_grid_size = grid_size; - } - Ok(Command::Stop) => { - eprintln!("[Sampler] Stop command received"); - return Ok(()); - } - Ok(Command::Start { .. }) => { - eprintln!("[Sampler] Ignoring nested start command"); - } - Err(TryRecvError::Disconnected) => { - eprintln!("[Sampler] Command channel disconnected"); - return Ok(()); - } - Err(TryRecvError::Empty) => { - // No command waiting, continue sampling - } - } - - let loop_start = std::time::Instant::now(); - - // Get cursor position (returns logical coordinates, DPI-aware) - let cursor_pos = match sampler.get_cursor_position() { - Ok(pos) => pos, - Err(_e) => { - // On Wayland/some platforms, we can't get cursor position directly - // Just use the last known position - last_cursor.clone() - } - }; - - // Sample every frame regardless of cursor movement for smooth updates - // This ensures the UI is responsive even if cursor position can't be tracked - last_cursor = cursor_pos.clone(); - - // Samplers handle DPI internally (like macOS), so pass coordinates directly - // Sample center pixel - let center_color = sampler.sample_pixel(cursor_pos.x, cursor_pos.y) - .unwrap_or_else(|e| { - eprintln!("Failed to sample center pixel: {}", e); - Color::new(128, 128, 128) - }); - - // Sample grid - let grid = sampler.sample_grid(cursor_pos.x, cursor_pos.y, current_grid_size, 1.0) - .unwrap_or_else(|e| { - eprintln!("Failed to sample grid: {}", e); - vec![vec![Color::new(128, 128, 128); current_grid_size]; current_grid_size] - }); - - // Convert to output format - let grid_data: Vec> = grid - .into_iter() - .map(|row| row.into_iter().map(ColorData::from).collect()) - .collect(); - - let pixel_data = PixelData { - cursor: cursor_pos.clone(), - center: center_color.into(), - grid: grid_data, - timestamp: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as u64, - }; - - // Send to stdout - if let Ok(json) = serde_json::to_string(&pixel_data) { - println!("{}", json); - let _ = io::stdout().flush(); - } - - sample_count += 1; - - // Print performance stats every 120 samples (every ~6 seconds at 20Hz) - if sample_count % 120 == 0 { - let elapsed = start_time.elapsed().as_secs_f64(); - let fps = sample_count as f64 / elapsed; - eprintln!("Sampling at {:.1} FPS (target: {} FPS)", fps, sample_rate); - - // Also report if we're consistently slow - if slow_frame_count > 0 { - eprintln!(" {} slow frames in last 120 samples", slow_frame_count); - slow_frame_count = 0; - } - } - - // Sleep to maintain sample rate - let elapsed = loop_start.elapsed(); - if elapsed < interval { - std::thread::sleep(interval - elapsed); - } else { - // Only log warnings occasionally, not every frame - slow_frame_count += 1; - if slow_frame_count == 1 || slow_frame_count % 30 == 0 { - eprintln!("Warning: frame took {}ms (target: {}ms)", elapsed.as_millis(), interval.as_millis()); - } - } - } -} - -fn send_error(error: &str) { - let error_response = ErrorResponse { - error: error.to_string(), - }; - if let Ok(json) = serde_json::to_string(&error_response) { - println!("{}", json); - let _ = io::stdout().flush(); - } -} diff --git a/electron-app/magnifier/rust-sampler/src/sampler/linux.rs b/electron-app/magnifier/rust-sampler/src/sampler/linux.rs deleted file mode 100644 index 0391312bb..000000000 --- a/electron-app/magnifier/rust-sampler/src/sampler/linux.rs +++ /dev/null @@ -1,278 +0,0 @@ -// Linux pixel sampler using X11 direct capture -// -// Uses native X11 XGetImage for fast, efficient pixel sampling on X11 systems. -// For Wayland systems, see wayland_portal.rs which uses XDG Desktop Portal + PipeWire. - -use crate::types::{Color, PixelSampler, Point}; -use std::ptr; -use std::sync::atomic::{AtomicBool, Ordering}; - -static X_ERROR_OCCURRED: AtomicBool = AtomicBool::new(false); - -// Custom X error handler that doesn't exit the process -unsafe extern "C" fn x_error_handler( - _display: *mut x11::xlib::Display, - _error_event: *mut x11::xlib::XErrorEvent, -) -> i32 { - X_ERROR_OCCURRED.store(true, Ordering::SeqCst); - 0 // Return 0 to indicate we handled it -} - -pub struct LinuxSampler { - x11_display: *mut x11::xlib::Display, - screen_width: i32, - screen_height: i32, - screenshot_cache: Option, -} - -struct ScreenshotCache { - data: Vec, - width: u32, - height: u32, - timestamp: std::time::Instant, -} - -impl LinuxSampler { - pub fn new() -> Result { - unsafe { - let display = x11::xlib::XOpenDisplay(ptr::null()); - if display.is_null() { - return Err("Failed to open X11 display".to_string()); - } - - // Install custom error handler - x11::xlib::XSetErrorHandler(Some(x_error_handler)); - - // Get screen dimensions - let screen = x11::xlib::XDefaultScreen(display); - let screen_width = x11::xlib::XDisplayWidth(display, screen); - let screen_height = x11::xlib::XDisplayHeight(display, screen); - - // Test X11 capture capability - Self::test_x11_capture(display)?; - - eprintln!("Linux sampler initialized - Screen: {}x{}", screen_width, screen_height); - - Ok(LinuxSampler { - x11_display: display, - screen_width, - screen_height, - screenshot_cache: None, - }) - } - } - - fn test_x11_capture(display: *mut x11::xlib::Display) -> Result<(), String> { - // Test X11 capture capability - unsafe { - X_ERROR_OCCURRED.store(false, Ordering::SeqCst); - - let root = x11::xlib::XDefaultRootWindow(display); - let test_image = x11::xlib::XGetImage( - display, - root, - 0, 0, 1, 1, - x11::xlib::XAllPlanes(), - x11::xlib::ZPixmap, - ); - - x11::xlib::XSync(display, 0); - - if !X_ERROR_OCCURRED.load(Ordering::SeqCst) && !test_image.is_null() { - x11::xlib::XDestroyImage(test_image); - return Ok(()); - } - - if !test_image.is_null() { - x11::xlib::XDestroyImage(test_image); - } - } - - Err("X11 capture failed".to_string()) - } - - fn capture_screenshot(&mut self) -> Result<(), String> { - self.capture_x11_region(0, 0, self.screen_width as u32, self.screen_height as u32) - } - - fn capture_x11_region(&mut self, x: i32, y: i32, width: u32, height: u32) -> Result<(), String> { - unsafe { - let root = x11::xlib::XDefaultRootWindow(self.x11_display); - - X_ERROR_OCCURRED.store(false, Ordering::SeqCst); - - let image = x11::xlib::XGetImage( - self.x11_display, - root, - x, y, width, height, - x11::xlib::XAllPlanes(), - x11::xlib::ZPixmap, - ); - - x11::xlib::XSync(self.x11_display, 0); - - if X_ERROR_OCCURRED.load(Ordering::SeqCst) || image.is_null() { - // Clean up XImage if it was allocated before returning error - if !image.is_null() { - x11::xlib::XDestroyImage(image); - } - return Err("X11 capture failed".to_string()); - } - - // Convert XImage to RGB buffer - let mut data = Vec::with_capacity((width * height * 3) as usize); - - // Read color masks from the XImage structure - let red_mask = (*image).red_mask; - let green_mask = (*image).green_mask; - let blue_mask = (*image).blue_mask; - - // Compute shift amounts by counting trailing zeros - let red_shift = red_mask.trailing_zeros(); - let green_shift = green_mask.trailing_zeros(); - let blue_shift = blue_mask.trailing_zeros(); - - // Compute mask bit widths for normalization - let red_bits = red_mask.count_ones(); - let green_bits = green_mask.count_ones(); - let blue_bits = blue_mask.count_ones(); - - // Compute normalization divisors (max value for each channel) - let red_max = (1u64 << red_bits) - 1; - let green_max = (1u64 << green_bits) - 1; - let blue_max = (1u64 << blue_bits) - 1; - - for row in 0..height { - for col in 0..width { - let pixel = x11::xlib::XGetPixel(image, col as i32, row as i32); - - // Extract raw channel values using masks and shifts - let r_raw = (pixel & red_mask) >> red_shift; - let g_raw = (pixel & green_mask) >> green_shift; - let b_raw = (pixel & blue_mask) >> blue_shift; - - // Normalize to 8-bit (0..255) - let r = ((r_raw * 255) / red_max) as u8; - let g = ((g_raw * 255) / green_max) as u8; - let b = ((b_raw * 255) / blue_max) as u8; - - data.push(r); - data.push(g); - data.push(b); - } - } - - x11::xlib::XDestroyImage(image); - - self.screenshot_cache = Some(ScreenshotCache { - data, - width, - height, - timestamp: std::time::Instant::now(), - }); - - Ok(()) - } - } - - fn ensure_fresh_screenshot(&mut self) -> Result<(), String> { - let needs_refresh = match &self.screenshot_cache { - None => true, - Some(cache) => cache.timestamp.elapsed().as_millis() > 50, // 50ms cache for 20 FPS - }; - - if needs_refresh { - self.capture_screenshot()?; - } - - Ok(()) - } -} - -impl Drop for LinuxSampler { - fn drop(&mut self) { - unsafe { - x11::xlib::XCloseDisplay(self.x11_display); - } - } -} - -impl PixelSampler for LinuxSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - self.ensure_fresh_screenshot()?; - - let cache = self.screenshot_cache.as_ref() - .ok_or("No screenshot cached")?; - - if x < 0 || y < 0 || x >= cache.width as i32 || y >= cache.height as i32 { - return Ok(Color::new(128, 128, 128)); - } - - let index = ((y as u32 * cache.width + x as u32) * 3) as usize; - - if index + 2 >= cache.data.len() { - return Ok(Color::new(128, 128, 128)); - } - - let r = cache.data[index]; - let g = cache.data[index + 1]; - let b = cache.data[index + 2]; - - Ok(Color::new(r, g, b)) - } - - fn get_cursor_position(&self) -> Result { - unsafe { - let root = x11::xlib::XDefaultRootWindow(self.x11_display); - - let mut root_return = 0; - let mut child_return = 0; - let mut root_x = 0; - let mut root_y = 0; - let mut win_x = 0; - let mut win_y = 0; - let mut mask_return = 0; - - let result = x11::xlib::XQueryPointer( - self.x11_display, - root, - &mut root_return, - &mut child_return, - &mut root_x, - &mut root_y, - &mut win_x, - &mut win_y, - &mut mask_return, - ); - - if result == 0 { - return Err("Failed to query pointer".to_string()); - } - - Ok(Point { x: root_x, y: root_y }) - } - } - - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { - // Ensure we have a fresh screenshot - self.ensure_fresh_screenshot()?; - - let half_size = (grid_size / 2) as i32; - let mut grid = Vec::with_capacity(grid_size); - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - let x = center_x + (col as i32 - half_size); - let y = center_y + (row as i32 - half_size); - - let color = self.sample_pixel(x, y) - .unwrap_or(Color::new(128, 128, 128)); - row_pixels.push(color); - } - grid.push(row_pixels); - } - - Ok(grid) - } -} diff --git a/electron-app/magnifier/rust-sampler/src/sampler/macos.rs b/electron-app/magnifier/rust-sampler/src/sampler/macos.rs deleted file mode 100644 index 7e4a078fa..000000000 --- a/electron-app/magnifier/rust-sampler/src/sampler/macos.rs +++ /dev/null @@ -1,183 +0,0 @@ -use crate::types::{Color, PixelSampler, Point}; -use core_graphics::display::{CGDisplay, CGPoint}; -use core_graphics::geometry::{CGRect, CGSize}; -use core_graphics::image::CGImage; - -pub struct MacOSSampler { - _display: CGDisplay, - _scale_factor: f64, -} - -// Native macOS APIs for window-aware screen capture -#[link(name = "CoreGraphics", kind = "framework")] -extern "C" { - fn CGWindowListCreateImage( - rect: CGRect, - list_option: u32, - window_id: u32, - image_option: u32, - ) -> *mut std::ffi::c_void; -} - -// CGWindowListOption constants -const K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY: u32 = 1 << 0; - -// CGWindowImageOption constants -const K_CG_WINDOW_IMAGE_BEST_RESOLUTION: u32 = 1 << 0; - -impl MacOSSampler { - pub fn new() -> Result { - let display = CGDisplay::main(); - - // Get display scale factor for Retina support - // On Retina displays, this will be 2.0; on standard displays, 1.0 - let bounds = display.bounds(); - let mode = display.display_mode(); - let scale_factor = if let Some(mode) = mode { - (mode.width() as f64) / bounds.size.width - } else { - 1.0 // Fallback to 1.0 if we can't determine - }; - - Ok(MacOSSampler { - _display: display, - _scale_factor: scale_factor, - }) - } - - // Capture screen region using CGWindowListCreateImage - // Windows with content protection enabled will be excluded automatically - fn capture_window_list_image(&self, rect: CGRect) -> Option { - unsafe { - // Use ON_SCREEN_ONLY to capture all visible windows - // Windows with setContentProtection(true) will be automatically excluded - let image_ref = CGWindowListCreateImage( - rect, - K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY, - 0, // kCGNullWindowID - include all windows (except protected ones) - K_CG_WINDOW_IMAGE_BEST_RESOLUTION, - ); - - if image_ref.is_null() { - None - } else { - // Create CGImage from raw sys pointer - let image: CGImage = std::mem::transmute(image_ref); - Some(image) - } - } - } -} - -impl PixelSampler for MacOSSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - // Capture a 1x1 pixel at the specified coordinates using window list - let rect = CGRect::new( - &CGPoint::new(x as f64, y as f64), - &CGSize::new(1.0, 1.0), - ); - - let image = self.capture_window_list_image(rect) - .ok_or_else(|| "Failed to capture screen pixel".to_string())?; - - let data = image.data(); - - // Need at least 4 bytes for BGRA format - if data.len() < 4 { - return Err("Insufficient image data".to_string()); - } - - // CGImage format is typically BGRA - let b = data[0]; - let g = data[1]; - let r = data[2]; - - Ok(Color::new(r, g, b)) - } - - fn get_cursor_position(&self) -> Result { - // Use Core Graphics to get current mouse position - unsafe { - // Call CGEventCreate which creates an event at the current cursor position - #[link(name = "CoreGraphics", kind = "framework")] - extern "C" { - fn CGEventCreate(source: *mut std::ffi::c_void) -> *mut std::ffi::c_void; - fn CGEventGetLocation(event: *mut std::ffi::c_void) -> CGPoint; - fn CFRelease(cf: *const std::ffi::c_void); - } - - let event_ref = CGEventCreate(std::ptr::null_mut()); - if event_ref.is_null() { - return Err("Failed to create CG event".to_string()); - } - - let point = CGEventGetLocation(event_ref); - CFRelease(event_ref as *const std::ffi::c_void); - - Ok(Point { - x: point.x as i32, - y: point.y as i32, - }) - } - } - - // Optimized grid sampling - capture once and sample from buffer - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { - let half_size = (grid_size / 2) as i32; - - // Calculate the region to capture - let x_start = center_x - half_size; - let y_start = center_y - half_size; - let width = grid_size as i32; - let height = grid_size as i32; - - // Capture the entire region in one screenshot using window list - let rect = CGRect::new( - &CGPoint::new(x_start as f64, y_start as f64), - &CGSize::new(width as f64, height as f64), - ); - - let image = self.capture_window_list_image(rect) - .ok_or_else(|| "Failed to capture screen region".to_string())?; - - let data = image.data(); - let bytes_per_row = image.bytes_per_row(); - let image_width = image.width() as usize; - let image_height = image.height() as usize; - let bits_per_pixel = image.bits_per_pixel(); - let bytes_per_pixel = (bits_per_pixel / 8) as usize; - - // Calculate scale factor - image might be 2x larger on Retina displays - let scale_x = image_width / grid_size; - let scale_y = image_height / grid_size; - - // Sample pixels from the captured image accounting for scale - let mut grid = Vec::with_capacity(grid_size); - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - // Account for Retina scaling - sample at scaled positions - let pixel_row = row * scale_y; - let pixel_col = col * scale_x; - - // Calculate offset in the image data - let offset = (pixel_row * bytes_per_row) + (pixel_col * bytes_per_pixel); - - if offset + bytes_per_pixel <= data.len() as usize { - // CGImage format is typically BGRA - let b = data[offset]; - let g = data[offset + 1]; - let r = data[offset + 2]; - - row_pixels.push(Color::new(r, g, b)); - } else { - row_pixels.push(Color::new(128, 128, 128)); - } - } - grid.push(row_pixels); - } - - Ok(grid) - } -} diff --git a/electron-app/magnifier/rust-sampler/src/sampler/mod.rs b/electron-app/magnifier/rust-sampler/src/sampler/mod.rs deleted file mode 100644 index 279307834..000000000 --- a/electron-app/magnifier/rust-sampler/src/sampler/mod.rs +++ /dev/null @@ -1,72 +0,0 @@ -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -pub use macos::MacOSSampler; - -#[cfg(all(target_os = "linux", feature = "x11"))] -mod linux; -#[cfg(all(target_os = "linux", feature = "x11"))] -pub use linux::LinuxSampler; - -// Wayland screencast with PipeWire (active implementation) -#[cfg(all(target_os = "linux", feature = "wayland"))] -mod wayland_portal; -#[cfg(all(target_os = "linux", feature = "wayland"))] -pub use wayland_portal::WaylandPortalSampler; - -#[cfg(target_os = "windows")] -mod windows; -#[cfg(target_os = "windows")] -pub use windows::WindowsSampler; - -use crate::types::PixelSampler; - -pub fn create_sampler() -> Result, String> { - #[cfg(target_os = "macos")] - { - Ok(Box::new(MacOSSampler::new()?)) - } - - #[cfg(target_os = "linux")] - { - // Try X11 direct capture first (best performance) - #[cfg(feature = "x11")] - { - match LinuxSampler::new() { - Ok(sampler) => { - return Ok(Box::new(sampler)); - } - Err(_e) => { - // X11 unavailable, try Wayland - } - } - } - - // Try Wayland Portal capture - #[cfg(feature = "wayland")] - { - match WaylandPortalSampler::new() { - Ok(mut sampler) => { - sampler.request_permission()?; - eprintln!("✓ Wayland Portal capture initialized"); - return Ok(Box::new(sampler)); - } - Err(e) => { - eprintln!("✗ Wayland Portal sampler failed: {}", e); - } - } - } - - Err("No screen capture method available. Build with --features x11 or --features wayland".to_string()) - } - - #[cfg(target_os = "windows")] - { - Ok(Box::new(WindowsSampler::new()?)) - } - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - Err("Unsupported platform".to_string()) - } -} diff --git a/electron-app/magnifier/rust-sampler/src/sampler/wayland_portal.rs b/electron-app/magnifier/rust-sampler/src/sampler/wayland_portal.rs deleted file mode 100644 index 28e9fbef9..000000000 --- a/electron-app/magnifier/rust-sampler/src/sampler/wayland_portal.rs +++ /dev/null @@ -1,436 +0,0 @@ -// Wayland screen capture using XDG Desktop Portal Screencast + PipeWire -// -// This implementation: -// 1. Uses ashpd to request screencast via Portal -// 2. Stores restore tokens to avoid repeated permission prompts -// 3. Takes a screenshot on each sample request (not live streaming) -// 4. Samples pixels from the screenshot buffer -// -// NOTE: This approach does not provide live updates like the macOS/Windows samplers. -// The screen is captured once per sample/grid request. This is a limitation of -// Wayland's security model - we cannot exclude windows (like the magnifier) from -// PipeWire video streams, so we use screenshots instead to avoid capturing the -// magnifier window overlay. - -use crate::types::{Color, PixelSampler, Point}; -use ashpd::desktop::screencast::{CursorMode, Screencast, SourceType}; -use ashpd::desktop::PersistMode; -use ashpd::WindowIdentifier; -use pipewire as pw; -use std::sync::{Arc, Mutex}; - -pub struct WaylandPortalSampler { - runtime: tokio::runtime::Runtime, - x11_display: *mut x11::xlib::Display, - screenshot_buffer: Arc>>, - pipewire_node_id: Option, - restore_token: Option, - screenshot_captured: bool, // Track if we've already captured the initial screenshot -} - -struct ScreenshotBuffer { - data: Vec, - width: u32, - height: u32, - stride: usize, -} - -impl WaylandPortalSampler { - pub fn new() -> Result { - // Still need X11 for cursor position - let x11_display = unsafe { - let display = x11::xlib::XOpenDisplay(std::ptr::null()); - if display.is_null() { - return Err("Failed to open X11 display for cursor tracking".to_string()); - } - display - }; - - // Create tokio runtime for async operations - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("Failed to create async runtime: {}", e))?; - - // Load any existing restore token - let restore_token = Self::load_restore_token(); - - Ok(WaylandPortalSampler { - runtime, - x11_display, - screenshot_buffer: Arc::new(Mutex::new(None)), - pipewire_node_id: None, - restore_token, - screenshot_captured: false, - }) - } - - fn load_restore_token() -> Option { - let token_path = dirs::data_local_dir()?.join("swach").join("screencast-token"); - std::fs::read_to_string(token_path).ok() - } - - fn save_restore_token(token: &str) -> Result<(), String> { - let data_dir = dirs::data_local_dir() - .ok_or("Could not find data directory")? - .join("swach"); - - std::fs::create_dir_all(&data_dir) - .map_err(|e| format!("Failed to create data directory: {}", e))?; - - let token_path = data_dir.join("screencast-token"); - std::fs::write(token_path, token) - .map_err(|e| format!("Failed to save restore token: {}", e))?; - - eprintln!("✓ Screen capture permission saved for future use"); - Ok(()) - } - - pub fn request_permission(&mut self) -> Result<(), String> { - self.ensure_screencast_permission() - } - - fn ensure_screencast_permission(&mut self) -> Result<(), String> { - // If we already have a node ID, permission is granted - if self.pipewire_node_id.is_some() { - return Ok(()); - } - - let restore_token = self.restore_token.clone(); - - if restore_token.is_none() { - eprintln!("Requesting screen capture permission..."); - } - - // Get the PipeWire node ID from the portal - let (node_id, new_token) = self.runtime.block_on(async { - // Connect to screencast portal - let screencast = Screencast::new().await - .map_err(|e| format!("Failed to connect to screencast portal: {}", e))?; - - // Create screencast session - let session = screencast.create_session().await - .map_err(|e| format!("Failed to create screencast session: {}", e))?; - - // Select sources - screencast - .select_sources( - &session, - CursorMode::Hidden, // Don't include cursor in screenshots - SourceType::Monitor.into(), - false, - restore_token.as_deref(), - PersistMode::ExplicitlyRevoked, - ) - .await - .map_err(|e| format!("Failed to select screencast sources: {}", e))?; - - // Start the screencast - let start_request = screencast - .start(&session, &WindowIdentifier::default()) - .await - .map_err(|e| format!("Failed to start screencast: {}", e))?; - - // Get the response - let streams_response = start_request.response() - .map_err(|e| format!("Failed to get screencast response: {}", e))?; - - eprintln!("✓ Screen capture permission granted"); - - // Get PipeWire node ID and restore token - let streams = streams_response.streams(); - if streams.is_empty() { - return Err("No PipeWire streams available".to_string()); - } - - let node_id = streams[0].pipe_wire_node_id(); - let new_token = streams_response.restore_token().map(|s| s.to_string()); - - Ok::<(u32, Option), String>((node_id, new_token)) - })?; - - // Save restore token if we got a new one - if let Some(token) = new_token { - self.restore_token = Some(token.clone()); - if let Err(e) = Self::save_restore_token(&token) { - eprintln!("Warning: Could not save permission token: {}", e); - } - } - - self.pipewire_node_id = Some(node_id); - eprintln!("✓ Screen capture initialized"); - - Ok(()) - } - - // Capture a screenshot from the PipeWire stream - fn capture_screenshot(&mut self) -> Result<(), String> { - let node_id = self.pipewire_node_id - .ok_or("Screen capture not initialized - call request_permission first")?; - - // Initialize PipeWire - pw::init(); - - // Create PipeWire main loop - let mainloop = pw::main_loop::MainLoopRc::new(None) - .map_err(|_| "Failed to create PipeWire main loop".to_string())?; - - // Create PipeWire context - let context = pw::context::ContextRc::new(&mainloop, None) - .map_err(|_| "Failed to create PipeWire context".to_string())?; - - // Connect to PipeWire core - let core = context.connect_rc(None) - .map_err(|_| "Failed to connect to PipeWire".to_string())?; - - // Create a stream - let stream = pw::stream::StreamBox::new( - &core, - "swach-screenshot", - pw::properties::properties! { - *pw::keys::MEDIA_TYPE => "Video", - *pw::keys::MEDIA_CATEGORY => "Capture", - *pw::keys::MEDIA_ROLE => "Screen", - }, - ).map_err(|_| "Failed to create PipeWire stream".to_string())?; - - // Video format info - let video_info: Arc>> = Arc::new(Mutex::new(None)); - - // Frame captured flag - let frame_captured = Arc::new(std::sync::atomic::AtomicBool::new(false)); - - // User data for callbacks - struct CallbackData { - screenshot_buffer: Arc>>, - frame_captured: Arc, - video_info: Arc>>, - mainloop: pw::main_loop::MainLoopRc, - } - - let callback_data = Arc::new(CallbackData { - screenshot_buffer: Arc::clone(&self.screenshot_buffer), - frame_captured: Arc::clone(&frame_captured), - video_info: Arc::clone(&video_info), - mainloop: mainloop.clone(), - }); - - // Add listener to receive one frame - let _listener = stream - .add_local_listener_with_user_data(callback_data) - .param_changed(move |_stream, user_data, id, param| { - use pw::spa::param::ParamType; - - if id != ParamType::Format.as_raw() { - return; - } - - if let Some(param) = param { - use pw::spa::param::video::VideoInfoRaw; - - if let Ok((_media_type, _media_subtype)) = pw::spa::param::format_utils::parse_format(param) { - let mut info = VideoInfoRaw::default(); - if let Ok(_) = info.parse(param) { - let size = info.size(); - let width = size.width; - let height = size.height; - let stride = width as usize * 4; // BGRA format - - if let Ok(mut vi) = user_data.video_info.lock() { - *vi = Some((width, height, stride)); - } - } - } - } - }) - .process(move |stream, user_data| { - // Only capture one frame - if user_data.frame_captured.load(std::sync::atomic::Ordering::SeqCst) { - return; - } - - match stream.dequeue_buffer() { - None => {} - Some(mut buffer) => { - let datas = buffer.datas_mut(); - if datas.is_empty() { - return; - } - - let data = &mut datas[0]; - let chunk = data.chunk(); - let size = chunk.size() as usize; - - if size == 0 { - return; - } - - // Get video format - let format_info = if let Ok(vi) = user_data.video_info.lock() { - *vi - } else { - None - }; - - // Get frame data - if let Some(slice) = data.data() { - if slice.len() >= size { - let pixel_data = slice[..size].to_vec(); - - if let Some((width, height, stride)) = format_info { - if width > 0 && height > 0 { - eprintln!("[Wayland] Captured screenshot: {}x{} ({} bytes)", width, height, pixel_data.len()); - if let Ok(mut buf) = user_data.screenshot_buffer.lock() { - *buf = Some(ScreenshotBuffer { - data: pixel_data, - width, - height, - stride, - }); - user_data.frame_captured.store(true, std::sync::atomic::Ordering::SeqCst); - user_data.mainloop.quit(); - } - } - } - } - } - } - } - }) - .register() - .map_err(|e| format!("Failed to register stream listener: {:?}", e))?; - - // Connect stream to the PipeWire node - stream.connect( - pw::spa::utils::Direction::Input, - Some(node_id), - pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, - &mut [], - ) - .map_err(|e| format!("Failed to connect stream: {:?}", e))?; - - // Run mainloop until we capture one frame or timeout - mainloop.run(); - - Ok(()) - } - - fn ensure_screenshot_captured(&mut self) -> Result<(), String> { - self.ensure_screencast_permission()?; - - if !self.screenshot_captured { - self.capture_screenshot()?; - self.screenshot_captured = true; - } - - Ok(()) - } -} - -impl Drop for WaylandPortalSampler { - fn drop(&mut self) { - unsafe { - x11::xlib::XCloseDisplay(self.x11_display); - } - } -} - -impl PixelSampler for WaylandPortalSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - self.ensure_screenshot_captured()?; - - let buffer = self.screenshot_buffer.lock().unwrap(); - let screenshot = buffer.as_ref() - .ok_or("No screenshot available")?; - - if x < 0 || y < 0 || x >= screenshot.width as i32 || y >= screenshot.height as i32 { - return Ok(Color::new(128, 128, 128)); - } - - let offset = (y as usize * screenshot.stride) + (x as usize * 4); - - if offset + 3 >= screenshot.data.len() { - return Ok(Color::new(128, 128, 128)); - } - - // Assuming BGRA format - let b = screenshot.data[offset]; - let g = screenshot.data[offset + 1]; - let r = screenshot.data[offset + 2]; - - Ok(Color::new(r, g, b)) - } - - fn get_cursor_position(&self) -> Result { - unsafe { - let root = x11::xlib::XDefaultRootWindow(self.x11_display); - - let mut root_return = 0; - let mut child_return = 0; - let mut root_x = 0; - let mut root_y = 0; - let mut win_x = 0; - let mut win_y = 0; - let mut mask_return = 0; - - let result = x11::xlib::XQueryPointer( - self.x11_display, - root, - &mut root_return, - &mut child_return, - &mut root_x, - &mut root_y, - &mut win_x, - &mut win_y, - &mut mask_return, - ); - - if result == 0 { - return Err("Failed to query pointer".to_string()); - } - - Ok(Point { x: root_x, y: root_y }) - } - } - - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { - self.ensure_screenshot_captured()?; - - let buffer = self.screenshot_buffer.lock().unwrap(); - let screenshot = buffer.as_ref() - .ok_or("No screenshot available")?; - - let half_size = (grid_size / 2) as i32; - let mut grid = Vec::with_capacity(grid_size); - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - let x = center_x + (col as i32 - half_size); - let y = center_y + (row as i32 - half_size); - - // Sample from the screenshot buffer - let color = if x < 0 || y < 0 || x >= screenshot.width as i32 || y >= screenshot.height as i32 { - Color::new(128, 128, 128) - } else { - let offset = (y as usize * screenshot.stride) + (x as usize * 4); - - if offset + 3 >= screenshot.data.len() { - Color::new(128, 128, 128) - } else { - // Assuming BGRA format - let b = screenshot.data[offset]; - let g = screenshot.data[offset + 1]; - let r = screenshot.data[offset + 2]; - Color::new(r, g, b) - } - }; - - row_pixels.push(color); - } - grid.push(row_pixels); - } - - Ok(grid) - } -} diff --git a/electron-app/magnifier/rust-sampler/src/sampler/windows.rs b/electron-app/magnifier/rust-sampler/src/sampler/windows.rs deleted file mode 100644 index 576cef768..000000000 --- a/electron-app/magnifier/rust-sampler/src/sampler/windows.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::types::{Color, PixelSampler, Point}; -use std::mem; -use windows::Win32::Foundation::POINT; -use windows::Win32::Graphics::Gdi::{ - BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, - GetDeviceCaps, GetDIBits, GetPixel, LOGPIXELSX, ReleaseDC, SelectObject, BITMAPINFO, - BITMAPINFOHEADER, BI_RGB, CLR_INVALID, DIB_RGB_COLORS, HDC, SRCCOPY, -}; -use windows::Win32::UI::HiDpi::{SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2}; -use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; - -pub struct WindowsSampler { - hdc: HDC, - pub dpi_scale: f64, -} - -impl WindowsSampler { - pub fn new() -> Result { - unsafe { - // Set DPI awareness to per-monitor v2 so we can access physical pixels - // This must be done before any GDI calls - // Ignore errors - if it fails, we'll fall back to system DPI awareness - let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); - - let hdc = GetDC(None); - - if hdc.is_invalid() { - return Err("Failed to get device context".to_string()); - } - - // Get DPI scaling factor - // GetDeviceCaps returns DPI (e.g., 96 for 100%, 192 for 200%) - // Standard DPI is 96, so scale = actual_dpi / 96 - let dpi = GetDeviceCaps(hdc, LOGPIXELSX); - let dpi_scale = dpi as f64 / 96.0; - - eprintln!("[WindowsSampler] DPI scale factor: {}", dpi_scale); - - Ok(WindowsSampler { - hdc, - dpi_scale, - }) - } - } -} - -impl Drop for WindowsSampler { - fn drop(&mut self) { - unsafe { - let _ = ReleaseDC(None, self.hdc); - } - } -} - -impl PixelSampler for WindowsSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - unsafe { - // With DPI awareness enabled, follow the macOS pattern: - // - x, y are logical coordinates (like CGWindowListCreateImage on macOS) - // - Convert to physical coordinates internally for GDI - let physical_x = (x as f64 * self.dpi_scale) as i32; - let physical_y = (y as f64 * self.dpi_scale) as i32; - - let color_ref = GetPixel(self.hdc, physical_x, physical_y); - - // Check for error (CLR_INVALID is returned on error) - // COLORREF is a newtype wrapper around u32 - if color_ref.0 == CLR_INVALID { - return Err(format!("Failed to get pixel at ({}, {})", x, y)); - } - - // Extract the u32 value from COLORREF newtype - let color_value = color_ref.0; - - // COLORREF format is 0x00BBGGRR (BGR, not RGB) - let r = (color_value & 0xFF) as u8; - let g = ((color_value >> 8) & 0xFF) as u8; - let b = ((color_value >> 16) & 0xFF) as u8; - - Ok(Color::new(r, g, b)) - } - } - - fn get_cursor_position(&self) -> Result { - unsafe { - let mut point = POINT { x: 0, y: 0 }; - - GetCursorPos(&mut point) - .map_err(|e| format!("Failed to get cursor position: {}", e))?; - - // With DPI awareness enabled, follow the macOS pattern: - // - GetCursorPos returns physical coordinates - // - Convert to logical coordinates (like macOS CGEventGetLocation) - // - This matches Electron's coordinate system and main.rs expectations - let logical_x = (point.x as f64 / self.dpi_scale) as i32; - let logical_y = (point.y as f64 / self.dpi_scale) as i32; - - Ok(Point { - x: logical_x, - y: logical_y, - }) - } - } - - // Optimized grid sampling using BitBlt for batch capture - // This is ~100x faster than calling GetPixel 81 times (for 9x9 grid) - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { - unsafe { - let half_size = (grid_size / 2) as i32; - - // With DPI awareness enabled, follow the macOS pattern: - // - center_x, center_y are logical coordinates - // - Convert to physical coordinates for GDI operations - let physical_center_x = (center_x as f64 * self.dpi_scale) as i32; - let physical_center_y = (center_y as f64 * self.dpi_scale) as i32; - - let x_start = physical_center_x - half_size; - let y_start = physical_center_y - half_size; - let width = grid_size as i32; - let height = grid_size as i32; - - // Create memory DC compatible with screen DC - let mem_dc = CreateCompatibleDC(self.hdc); - if mem_dc.is_invalid() { - return Err("Failed to create compatible DC".to_string()); - } - - // Create compatible bitmap - let bitmap = CreateCompatibleBitmap(self.hdc, width, height); - if bitmap.is_invalid() { - let _ = DeleteDC(mem_dc); - return Err("Failed to create compatible bitmap".to_string()); - } - - // Select bitmap into memory DC - let old_bitmap = SelectObject(mem_dc, bitmap); - - // Copy screen region to memory bitmap using BitBlt - // This is the key optimization - ONE API call instead of grid_size^2 calls - if let Err(_) = BitBlt( - mem_dc, - 0, - 0, - width, - height, - self.hdc, - x_start, - y_start, - SRCCOPY, - ) { - // BitBlt failed - clean up and fall back to default implementation - SelectObject(mem_dc, old_bitmap); - let _ = DeleteObject(bitmap); - let _ = DeleteDC(mem_dc); - - return self.sample_grid_fallback(center_x, center_y, grid_size); - } - - // Prepare bitmap info for GetDIBits - let mut bmi = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: mem::size_of::() as u32, - biWidth: width, - biHeight: -height, // Negative for top-down DIB - biPlanes: 1, - biBitCount: 32, // 32-bit BGRA - biCompression: BI_RGB.0 as u32, - biSizeImage: 0, - biXPelsPerMeter: 0, - biYPelsPerMeter: 0, - biClrUsed: 0, - biClrImportant: 0, - }, - bmiColors: [Default::default(); 1], - }; - - // Allocate buffer for pixel data (4 bytes per pixel: BGRA) - let buffer_size = (width * height * 4) as usize; - let mut buffer: Vec = vec![0; buffer_size]; - - // Get bitmap bits - let scan_lines = GetDIBits( - mem_dc, - bitmap, - 0, - height as u32, - Some(buffer.as_mut_ptr() as *mut _), - &mut bmi, - DIB_RGB_COLORS, - ); - - // Clean up GDI resources - SelectObject(mem_dc, old_bitmap); - let _ = DeleteObject(bitmap); - let _ = DeleteDC(mem_dc); - - if scan_lines == 0 { - return self.sample_grid_fallback(center_x, center_y, grid_size); - } - - // Parse buffer and build grid - let mut grid = Vec::with_capacity(grid_size); - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - // Calculate offset in buffer (BGRA format, 4 bytes per pixel) - let offset = ((row * grid_size + col) * 4) as usize; - - if offset + 3 < buffer.len() { - // Windows DIB format is BGRA - let b = buffer[offset]; - let g = buffer[offset + 1]; - let r = buffer[offset + 2]; - // Alpha channel at offset + 3 is ignored - - row_pixels.push(Color::new(r, g, b)); - } else { - // Fallback for out-of-bounds - row_pixels.push(Color::new(128, 128, 128)); - } - } - grid.push(row_pixels); - } - - Ok(grid) - } - } -} - -impl WindowsSampler { - // Fallback to default pixel-by-pixel sampling if BitBlt fails - fn sample_grid_fallback(&mut self, center_x: i32, center_y: i32, grid_size: usize) -> Result>, String> { - unsafe { - let half_size = (grid_size / 2) as i32; - let mut grid = Vec::with_capacity(grid_size); - - // center_x, center_y are logical coordinates - convert to physical - let physical_center_x = (center_x as f64 * self.dpi_scale) as i32; - let physical_center_y = (center_y as f64 * self.dpi_scale) as i32; - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - // Calculate physical pixel coordinates - let x = physical_center_x + (col as i32 - half_size); - let y = physical_center_y + (row as i32 - half_size); - - let color_ref = GetPixel(self.hdc, x, y); - - let color = if color_ref.0 == CLR_INVALID { - Color::new(128, 128, 128) // Gray fallback for out-of-bounds - } else { - let color_value = color_ref.0; - let r = (color_value & 0xFF) as u8; - let g = ((color_value >> 8) & 0xFF) as u8; - let b = ((color_value >> 16) & 0xFF) as u8; - Color::new(r, g, b) - }; - - row_pixels.push(color); - } - grid.push(row_pixels); - } - - Ok(grid) - } - } -} diff --git a/electron-app/magnifier/rust-sampler/src/types.rs b/electron-app/magnifier/rust-sampler/src/types.rs deleted file mode 100644 index c98bd286c..000000000 --- a/electron-app/magnifier/rust-sampler/src/types.rs +++ /dev/null @@ -1,106 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct Color { - pub r: u8, - pub g: u8, - pub b: u8, - pub hex: [u8; 7], // "#RRGGBB" -} - -impl Color { - pub fn new(r: u8, g: u8, b: u8) -> Self { - let hex = format!("#{:02X}{:02X}{:02X}", r, g, b); - let mut hex_bytes = [0u8; 7]; - hex_bytes.copy_from_slice(hex.as_bytes()); - - Color { r, g, b, hex: hex_bytes } - } - - pub fn hex_string(&self) -> String { - String::from_utf8_lossy(&self.hex).to_string() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Point { - pub x: i32, - pub y: i32, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PixelData { - pub cursor: Point, - pub center: ColorData, - pub grid: Vec>, - pub timestamp: u64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ColorData { - pub r: u8, - pub g: u8, - pub b: u8, - pub hex: String, -} - -impl From for ColorData { - fn from(color: Color) -> Self { - ColorData { - r: color.r, - g: color.g, - b: color.b, - hex: color.hex_string(), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "command")] -pub enum Command { - #[serde(rename = "start")] - Start { - grid_size: usize, - sample_rate: u64, - }, - #[serde(rename = "update_grid")] - UpdateGrid { - grid_size: usize, - }, - #[serde(rename = "stop")] - Stop, -} - -#[derive(Debug, Serialize)] -pub struct ErrorResponse { - pub error: String, -} - -pub trait PixelSampler { - /// Get the color of a single pixel at the given coordinates - fn sample_pixel(&mut self, x: i32, y: i32) -> Result; - - /// Get cursor position - fn get_cursor_position(&self) -> Result; - - /// Sample a grid of pixels around a center point - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { - let half_size = (grid_size / 2) as i32; - let mut grid = Vec::with_capacity(grid_size); - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - let x = center_x + (col as i32 - half_size); - let y = center_y + (row as i32 - half_size); - - let color = self.sample_pixel(x, y) - .unwrap_or(Color::new(128, 128, 128)); // Gray fallback - row_pixels.push(color); - } - grid.push(row_pixels); - } - - Ok(grid) - } -} diff --git a/electron-app/magnifier/rust-sampler/tests/command_processing_tests.rs b/electron-app/magnifier/rust-sampler/tests/command_processing_tests.rs deleted file mode 100644 index 2f1e197cf..000000000 --- a/electron-app/magnifier/rust-sampler/tests/command_processing_tests.rs +++ /dev/null @@ -1,290 +0,0 @@ -// Command processing and JSON parsing tests -// These tests verify the command deserialization and error handling - -use swach_sampler::types::{Command, ErrorResponse, PixelData, ColorData, Point}; -use serde_json; - -#[test] -fn test_start_command_deserialization() { - let json = r#"{"command":"start","grid_size":9,"sample_rate":20}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Start { grid_size, sample_rate } => { - assert_eq!(grid_size, 9); - assert_eq!(sample_rate, 20); - } - _ => panic!("Expected Start command"), - } -} - -#[test] -fn test_start_command_with_large_grid() { - let json = r#"{"command":"start","grid_size":21,"sample_rate":60}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Start { grid_size, sample_rate } => { - assert_eq!(grid_size, 21); - assert_eq!(sample_rate, 60); - } - _ => panic!("Expected Start command"), - } -} - -#[test] -fn test_update_grid_command_deserialization() { - let json = r#"{"command":"update_grid","grid_size":15}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::UpdateGrid { grid_size } => { - assert_eq!(grid_size, 15); - } - _ => panic!("Expected UpdateGrid command"), - } -} - -#[test] -fn test_update_grid_minimum_size() { - let json = r#"{"command":"update_grid","grid_size":5}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::UpdateGrid { grid_size } => { - assert_eq!(grid_size, 5); - } - _ => panic!("Expected UpdateGrid command"), - } -} - -#[test] -fn test_update_grid_maximum_size() { - let json = r#"{"command":"update_grid","grid_size":21}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::UpdateGrid { grid_size } => { - assert_eq!(grid_size, 21); - } - _ => panic!("Expected UpdateGrid command"), - } -} - -#[test] -fn test_stop_command_deserialization() { - let json = r#"{"command":"stop"}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Stop => {} - _ => panic!("Expected Stop command"), - } -} - -#[test] -fn test_invalid_command_fails() { - let json = r#"{"command":"invalid"}"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err(), "Invalid command should fail to parse"); -} - -#[test] -fn test_missing_grid_size_fails() { - let json = r#"{"command":"start","sample_rate":20}"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err(), "Start command missing grid_size should fail"); -} - -#[test] -fn test_missing_sample_rate_fails() { - let json = r#"{"command":"start","grid_size":9}"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err(), "Start command missing sample_rate should fail"); -} - -#[test] -fn test_malformed_json_fails() { - let json = r#"{"command":"start","grid_size":9"#; // Missing closing brace - let result: Result = serde_json::from_str(json); - assert!(result.is_err(), "Malformed JSON should fail to parse"); -} - -#[test] -fn test_error_response_serialization() { - let error = ErrorResponse { - error: "Test error message".to_string(), - }; - let json = serde_json::to_string(&error).unwrap(); - assert!(json.contains("Test error message")); - assert!(json.contains("error")); -} - -#[test] -fn test_pixel_data_serialization() { - let pixel_data = PixelData { - cursor: Point { x: 100, y: 200 }, - center: ColorData { - r: 255, - g: 128, - b: 64, - hex: "#FF8040".to_string(), - }, - grid: vec![vec![ - ColorData { - r: 0, - g: 0, - b: 0, - hex: "#000000".to_string(), - }, - ColorData { - r: 255, - g: 255, - b: 255, - hex: "#FFFFFF".to_string(), - }, - ]], - timestamp: 1234567890, - }; - - let json = serde_json::to_string(&pixel_data).unwrap(); - - // Verify all expected fields are present - assert!(json.contains("\"cursor\"")); - assert!(json.contains("\"x\":100")); - assert!(json.contains("\"y\":200")); - assert!(json.contains("\"center\"")); - assert!(json.contains("\"r\":255")); - assert!(json.contains("\"g\":128")); - assert!(json.contains("\"b\":64")); - assert!(json.contains("#FF8040")); - assert!(json.contains("\"grid\"")); - assert!(json.contains("#000000")); - assert!(json.contains("#FFFFFF")); - assert!(json.contains("\"timestamp\":1234567890")); -} - -#[test] -fn test_pixel_data_deserialization() { - let json = r##"{"cursor":{"x":50,"y":75},"center":{"r":100,"g":150,"b":200,"hex":"#6496C8"},"grid":[[{"r":10,"g":20,"b":30,"hex":"#0A141E"}]],"timestamp":9876543210}"##; - - let pixel_data: PixelData = serde_json::from_str(json).unwrap(); - - assert_eq!(pixel_data.cursor.x, 50); - assert_eq!(pixel_data.cursor.y, 75); - assert_eq!(pixel_data.center.r, 100); - assert_eq!(pixel_data.center.g, 150); - assert_eq!(pixel_data.center.b, 200); - assert_eq!(pixel_data.center.hex, "#6496C8"); - assert_eq!(pixel_data.grid.len(), 1); - assert_eq!(pixel_data.grid[0].len(), 1); - assert_eq!(pixel_data.grid[0][0].r, 10); - assert_eq!(pixel_data.grid[0][0].hex, "#0A141E"); - assert_eq!(pixel_data.timestamp, 9876543210); -} - -#[test] -fn test_command_with_extra_fields_ignored() { - // JSON with extra fields should still parse successfully (forward compatibility) - let json = r#"{"command":"stop","extra_field":"ignored"}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Stop => {} - _ => panic!("Expected Stop command"), - } -} - -#[test] -fn test_start_command_with_zero_sample_rate() { - // Zero sample rate should parse but may be invalid at runtime - let json = r#"{"command":"start","grid_size":9,"sample_rate":0}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Start { grid_size, sample_rate } => { - assert_eq!(grid_size, 9); - assert_eq!(sample_rate, 0); - } - _ => panic!("Expected Start command"), - } -} - -#[test] -fn test_large_grid_data_serialization() { - // Test serialization of a large 21x21 grid - let mut grid = Vec::new(); - for row in 0..21 { - let mut row_data = Vec::new(); - for col in 0..21 { - row_data.push(ColorData { - r: (row * 10) as u8, - g: (col * 10) as u8, - b: 128, - hex: format!("#{:02X}{:02X}80", row * 10, col * 10), - }); - } - grid.push(row_data); - } - - let pixel_data = PixelData { - cursor: Point { x: 500, y: 500 }, - center: ColorData { - r: 100, - g: 100, - b: 128, - hex: "#646480".to_string(), - }, - grid, - timestamp: 1234567890, - }; - - let json = serde_json::to_string(&pixel_data).unwrap(); - - // Should be able to serialize without errors - assert!(json.len() > 1000); // Large grid should produce substantial JSON - assert!(json.contains("\"grid\"")); - - // Should be able to deserialize back - let parsed: PixelData = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.grid.len(), 21); - assert_eq!(parsed.grid[0].len(), 21); -} - -#[test] -fn test_negative_cursor_coordinates() { - let pixel_data = PixelData { - cursor: Point { x: -10, y: -20 }, - center: ColorData { - r: 0, - g: 0, - b: 0, - hex: "#000000".to_string(), - }, - grid: vec![vec![ColorData { - r: 0, - g: 0, - b: 0, - hex: "#000000".to_string(), - }]], - timestamp: 0, - }; - - let json = serde_json::to_string(&pixel_data).unwrap(); - assert!(json.contains("\"x\":-10")); - assert!(json.contains("\"y\":-20")); - - let parsed: PixelData = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.cursor.x, -10); - assert_eq!(parsed.cursor.y, -20); -} - -#[test] -fn test_command_case_sensitivity() { - // Commands should be case-sensitive - let json = r#"{"command":"Start","grid_size":9,"sample_rate":20}"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err(), "Command should be case-sensitive"); -} - -#[test] -fn test_unicode_in_error_message() { - let error = ErrorResponse { - error: "Error with unicode: 🎨 测试".to_string(), - }; - let json = serde_json::to_string(&error).unwrap(); - // ErrorResponse is Serialize only, not Deserialize, so just verify serialization - assert!(json.contains("Error with unicode")); -} diff --git a/electron-app/magnifier/rust-sampler/tests/linux_sampler_tests.rs b/electron-app/magnifier/rust-sampler/tests/linux_sampler_tests.rs deleted file mode 100644 index ead3e7517..000000000 --- a/electron-app/magnifier/rust-sampler/tests/linux_sampler_tests.rs +++ /dev/null @@ -1,294 +0,0 @@ -// Linux X11-specific sampler tests -// Only compiled and run on Linux with x11 feature - -#![cfg(all(target_os = "linux", feature = "x11"))] - -use swach_sampler::types::{Color, PixelSampler, Point}; - -// Mock Linux X11 sampler for testing -struct MockLinuxSampler { - screen_width: i32, - screen_height: i32, - has_screenshot_cache: bool, -} - -impl MockLinuxSampler { - fn new(width: i32, height: i32) -> Self { - MockLinuxSampler { - screen_width: width, - screen_height: height, - has_screenshot_cache: false, - } - } - - fn simulate_cache(&mut self) { - self.has_screenshot_cache = true; - } -} - -impl PixelSampler for MockLinuxSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - // Simulate X11 XGetPixel behavior - if x < 0 || y < 0 || x >= self.screen_width || y >= self.screen_height { - return Err("X11 capture failed".to_string()); - } - - // Simulate color extraction with bit masks - // X11 typically uses various color masks depending on screen depth - let pixel_value = (x as u32) << 16 | (y as u32) << 8 | ((x + y) as u32); - - // Extract RGB from pixel value (simulating mask operations) - let r = ((pixel_value >> 16) & 0xFF) as u8; - let g = ((pixel_value >> 8) & 0xFF) as u8; - let b = (pixel_value & 0xFF) as u8; - - Ok(Color::new(r, g, b)) - } - - fn get_cursor_position(&self) -> Result { - // Simulate X11 cursor position query - Ok(Point { x: 100, y: 100 }) - } -} - -#[test] -fn test_linux_sampler_basic_sampling() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - let color = sampler.sample_pixel(100, 200).unwrap(); - - // Colors are u8, so they're always in valid range (0-255) - // Just verify we got a color successfully - let _r = color.r; - let _g = color.g; - let _b = color.b; -} - -#[test] -fn test_linux_sampler_x11_error_handling() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Test negative coordinates (should trigger X11 error) - let result = sampler.sample_pixel(-10, -10); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("X11 capture failed")); -} - -#[test] -fn test_linux_sampler_bounds_checking() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Valid coordinates - assert!(sampler.sample_pixel(0, 0).is_ok()); - assert!(sampler.sample_pixel(1919, 1079).is_ok()); - - // Invalid coordinates - assert!(sampler.sample_pixel(-1, 0).is_err()); - assert!(sampler.sample_pixel(0, -1).is_err()); - assert!(sampler.sample_pixel(1920, 1080).is_err()); -} - -#[test] -fn test_linux_sampler_cursor_position() { - let sampler = MockLinuxSampler::new(1920, 1080); - - let cursor = sampler.get_cursor_position().unwrap(); - assert_eq!(cursor.x, 100); - assert_eq!(cursor.y, 100); -} - -#[test] -fn test_linux_sampler_grid_sampling() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - let grid = sampler.sample_grid(500, 500, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); - assert_eq!(grid[0].len(), 5); -} - -#[test] -fn test_linux_sampler_screenshot_cache_behavior() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Initially no cache - assert!(!sampler.has_screenshot_cache); - - // Simulate cache creation - sampler.simulate_cache(); - assert!(sampler.has_screenshot_cache); -} - -#[test] -fn test_linux_sampler_color_mask_extraction() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Test that color mask extraction produces valid colors - let _color = sampler.sample_pixel(255, 128).unwrap(); - - let hex = _color.hex_string(); - assert!(hex.starts_with('#')); - assert_eq!(hex.len(), 7); -} - -#[test] -fn test_linux_sampler_various_screen_depths() { - // X11 supports various color depths (16, 24, 32 bit) - // Our sampler should handle all of them - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Sample various points - colors are u8 so always in 0-255 range - for x in [0, 100, 500, 1000, 1919] { - for y in [0, 100, 500, 1079] { - let _color = sampler.sample_pixel(x, y).unwrap(); - // Successfully got a color, that's all we need to verify - } - } -} - -#[test] -fn test_linux_sampler_multi_display() { - // Linux can have complex multi-display setups with X11 - // Test extended display (horizontal) - let mut sampler = MockLinuxSampler::new(3840, 1080); - - let color = sampler.sample_pixel(2500, 500).unwrap(); - assert!(color.r <= 255); -} - -#[test] -fn test_linux_sampler_grid_sizes() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - for size in [5, 7, 9, 11, 13, 15, 17, 19, 21] { - let grid = sampler.sample_grid(960, 540, size, 1.0).unwrap(); - assert_eq!(grid.len(), size); - assert_eq!(grid[0].len(), size); - } -} - -#[test] -fn test_linux_sampler_grid_at_screen_edge() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Sample at edges where some pixels will be OOB - // Center at (1, 1) with 5x5 grid samples from (-1,-1) to (3,3) - let grid = sampler.sample_grid(1, 1, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); - - // Top-left pixel at (-1, -1) should be OOB and return fallback gray - let pixel = &grid[0][0]; - assert_eq!(pixel.r, 128); - assert_eq!(pixel.g, 128); - assert_eq!(pixel.b, 128); - - // Center pixel at (1, 1) should be valid - let center = &grid[2][2]; - assert_eq!(center.r, (1u32 & 0xFF) as u8); - assert_eq!(center.g, (1u32 & 0xFF) as u8); -} - -#[test] -fn test_linux_sampler_high_resolution() { - // Test 4K resolution - let mut sampler = MockLinuxSampler::new(3840, 2160); - - let _color = sampler.sample_pixel(1920, 1080).unwrap(); - - let grid = sampler.sample_grid(1920, 1080, 11, 1.0).unwrap(); - assert_eq!(grid.len(), 11); -} - -#[test] -fn test_linux_sampler_rapid_sampling() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Simulate rapid sampling (20Hz sample rate) - for i in 0..100 { - let x = 500 + (i % 100); - let y = 500 + (i % 100); - let result = sampler.sample_pixel(x, y); - assert!(result.is_ok(), "Sample {} failed", i); - } -} - -#[test] -fn test_linux_sampler_x11_sync_behavior() { - // X11 requires XSync calls to ensure operations complete - // Our mock simulates this - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Multiple sequential samples should all succeed - for _ in 0..10 { - let result = sampler.sample_pixel(500, 500); - assert!(result.is_ok(), "XSync issue detected"); - } -} - -#[test] -fn test_linux_sampler_color_normalization() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // Test that various color depths are normalized to 8-bit (0-255) - // Colors are u8, so they're always in valid range by type definition - let _color = sampler.sample_pixel(200, 100).unwrap(); -} - -#[test] -fn test_linux_sampler_grid_center_alignment() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - let center_x = 500; - let center_y = 500; - let grid_size = 9; - - let grid = sampler.sample_grid(center_x, center_y, grid_size, 1.0).unwrap(); - - // Verify center pixel - let center_idx = grid_size / 2; - let center_pixel = &grid[center_idx][center_idx]; - let expected = sampler.sample_pixel(center_x, center_y).unwrap(); - - assert_eq!(center_pixel.r, expected.r); - assert_eq!(center_pixel.g, expected.g); - assert_eq!(center_pixel.b, expected.b); -} - -#[test] -fn test_linux_sampler_error_recovery() { - let mut sampler = MockLinuxSampler::new(1920, 1080); - - // After an error, sampler should still work - let _ = sampler.sample_pixel(-1, -1); // Error - - let result = sampler.sample_pixel(100, 100); // Should succeed - assert!(result.is_ok()); -} - -#[test] -fn test_linux_sampler_various_resolutions() { - let resolutions = [ - (1366, 768), // Common laptop - (1920, 1080), // Full HD - (2560, 1440), // QHD - (3840, 2160), // 4K - ]; - - for (width, height) in resolutions { - let mut sampler = MockLinuxSampler::new(width, height); - - let x = width / 2; - let y = height / 2; - let result = sampler.sample_pixel(x, y); - assert!(result.is_ok(), "Failed for {}x{}", width, height); - } -} - -#[test] -fn test_linux_sampler_display_scaling() { - // Linux X11 with HiDPI scaling - let mut sampler = MockLinuxSampler::new(3840, 2160); - - // Should work regardless of logical scaling - let grid = sampler.sample_grid(1920, 1080, 9, 2.0).unwrap(); - assert_eq!(grid.len(), 9); -} diff --git a/electron-app/magnifier/rust-sampler/tests/macos_sampler_tests.rs b/electron-app/magnifier/rust-sampler/tests/macos_sampler_tests.rs deleted file mode 100644 index 3ec7f836c..000000000 --- a/electron-app/magnifier/rust-sampler/tests/macos_sampler_tests.rs +++ /dev/null @@ -1,281 +0,0 @@ -// macOS-specific sampler tests -// Only compiled and run on macOS - -#![cfg(target_os = "macos")] - -use swach_sampler::types::{Color, PixelSampler, Point}; - -// Note: These tests use a mock sampler because we can't easily test -// the actual CGDisplay APIs in CI without a display. -// Integration tests on real hardware would require manual testing. - -struct MockMacOSSampler { - screen_width: i32, - screen_height: i32, - _scale_factor: f64, -} - -impl MockMacOSSampler { - fn new(width: i32, height: i32, scale_factor: f64) -> Self { - MockMacOSSampler { - screen_width: width, - screen_height: height, - _scale_factor: scale_factor, - } - } -} - -impl PixelSampler for MockMacOSSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - // Simulate macOS coordinate system and bounds checking - if x < 0 || y < 0 || x >= self.screen_width || y >= self.screen_height { - // Out of bounds - return gray like the real sampler would - Ok(Color::new(128, 128, 128)) - } else { - // Return a color based on position for testing - // Simulates BGRA to RGB conversion - let r = (x % 256) as u8; - let g = (y % 256) as u8; - let b = ((x + y) % 256) as u8; - Ok(Color::new(r, g, b)) - } - } - - fn get_cursor_position(&self) -> Result { - // Simulate getting cursor position - Ok(Point { x: 100, y: 100 }) - } - - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, scale_factor: f64) -> Result>, String> { - // Test that scale_factor is passed through - assert!(scale_factor > 0.0, "Scale factor should be positive"); - - // Use default implementation - let half_size = (grid_size / 2) as i32; - let mut grid = Vec::with_capacity(grid_size); - - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - let x = center_x + (col as i32 - half_size); - let y = center_y + (row as i32 - half_size); - - let color = self.sample_pixel(x, y)?; - row_pixels.push(color); - } - grid.push(row_pixels); - } - - Ok(grid) - } -} - -#[test] -fn test_macos_sampler_basic_sampling() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 2.0); - - // Test basic pixel sampling - let color = sampler.sample_pixel(100, 200).unwrap(); - assert_eq!(color.r, 100); - assert_eq!(color.g, 200); -} - -#[test] -fn test_macos_sampler_retina_scale_factor() { - let mut sampler = MockMacOSSampler::new(2880, 1800, 2.0); - - // On Retina displays, logical coordinates are half of physical - // The sampler should handle this correctly - let grid = sampler.sample_grid(100, 100, 3, 2.0).unwrap(); - assert_eq!(grid.len(), 3); - assert_eq!(grid[0].len(), 3); -} - -#[test] -fn test_macos_sampler_standard_display() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Standard display with 1.0 scale factor - let grid = sampler.sample_grid(100, 100, 3, 1.0).unwrap(); - assert_eq!(grid.len(), 3); - assert_eq!(grid[0].len(), 3); -} - -#[test] -fn test_macos_sampler_bounds_checking() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Test negative coordinates - let color = sampler.sample_pixel(-10, -10).unwrap(); - assert_eq!(color.r, 128); - assert_eq!(color.g, 128); - assert_eq!(color.b, 128); - - // Test coordinates beyond screen bounds - let color = sampler.sample_pixel(2000, 1100).unwrap(); - assert_eq!(color.r, 128); - assert_eq!(color.g, 128); - assert_eq!(color.b, 128); -} - -#[test] -fn test_macos_sampler_grid_at_origin() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Sample at origin - some pixels will be out of bounds - let grid = sampler.sample_grid(0, 0, 3, 1.0).unwrap(); - assert_eq!(grid.len(), 3); - - // Top-left should be out of bounds (gray) - assert_eq!(grid[0][0].r, 128); - assert_eq!(grid[0][0].g, 128); - - // Center should be valid (at 0, 0) - assert_eq!(grid[1][1].r, 0); - assert_eq!(grid[1][1].g, 0); -} - -#[test] -fn test_macos_sampler_grid_at_screen_edge() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Sample at bottom-right corner - let grid = sampler.sample_grid(1919, 1079, 3, 1.0).unwrap(); - assert_eq!(grid.len(), 3); - - // Bottom-right should be out of bounds for some pixels - assert_eq!(grid[2][2].r, 128); - assert_eq!(grid[2][2].g, 128); -} - -#[test] -fn test_macos_sampler_large_grid() { - let mut sampler = MockMacOSSampler::new(3840, 2160, 2.0); - - // Test maximum grid size (21x21) - let grid = sampler.sample_grid(1000, 1000, 21, 2.0).unwrap(); - assert_eq!(grid.len(), 21); - assert_eq!(grid[0].len(), 21); - - // Verify center pixel - let center = &grid[10][10]; - assert_eq!(center.r, (1000 % 256) as u8); - assert_eq!(center.g, (1000 % 256) as u8); -} - -#[test] -fn test_macos_sampler_odd_grid_sizes() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Test various odd grid sizes - for size in [5, 7, 9, 11, 13, 15, 17, 19, 21] { - let grid = sampler.sample_grid(500, 500, size, 1.0).unwrap(); - assert_eq!(grid.len(), size); - assert_eq!(grid[0].len(), size); - - // Verify center pixel is at the correct position - let center_idx = size / 2; - let center = &grid[center_idx][center_idx]; - // Center should be at (500, 500) - assert_eq!(center.r, (500 % 256) as u8); - assert_eq!(center.g, (500 % 256) as u8); - } -} - -#[test] -fn test_macos_sampler_cursor_position() { - let sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - let cursor = sampler.get_cursor_position().unwrap(); - assert_eq!(cursor.x, 100); - assert_eq!(cursor.y, 100); -} - -#[test] -fn test_macos_sampler_color_format() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Test that colors are in RGB format (not BGRA) - let color = sampler.sample_pixel(255, 128).unwrap(); - - // Colors are u8, so they're always in valid range (0-255) - // Just verify we got a color successfully - - // Verify hex string format - let hex = color.hex_string(); - assert!(hex.starts_with('#')); - assert_eq!(hex.len(), 7); -} - -#[test] -fn test_macos_sampler_grid_symmetry() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // For odd grid sizes, the center should be equidistant from edges - let grid = sampler.sample_grid(500, 500, 9, 1.0).unwrap(); - - let center_idx = 4; - - // Distance from center to edge should be 4 in all directions - assert_eq!(center_idx, 4); - assert_eq!(grid.len() - center_idx - 1, 4); -} - -#[test] -fn test_macos_sampler_multiple_scale_factors() { - // Test various scale factors that might be encountered - for scale in [1.0, 1.5, 2.0, 2.5, 3.0] { - let mut sampler = MockMacOSSampler::new( - (1920.0 * scale) as i32, - (1080.0 * scale) as i32, - scale - ); - - let grid = sampler.sample_grid(100, 100, 5, scale).unwrap(); - assert_eq!(grid.len(), 5); - assert_eq!(grid[0].len(), 5); - } -} - -#[test] -fn test_macos_sampler_high_resolution_display() { - // Test 5K iMac resolution (5120x2880 at 2x scale) - let mut sampler = MockMacOSSampler::new(5120, 2880, 2.0); - - // Sample in the middle of the screen - let grid = sampler.sample_grid(2560, 1440, 11, 2.0).unwrap(); - assert_eq!(grid.len(), 11); - - // All pixels should be in bounds - for row in &grid { - for color in row { - // Gray color indicates out of bounds, so we shouldn't see it here - if color.r == 128 && color.g == 128 && color.b == 128 { - // This is acceptable for our mock, but worth noting - } - } - } -} - -#[test] -fn test_macos_sampler_grid_pixel_order() { - let mut sampler = MockMacOSSampler::new(1920, 1080, 1.0); - - // Verify that grid pixels are in the correct order (top-left to bottom-right) - let grid = sampler.sample_grid(500, 500, 3, 1.0).unwrap(); - - // Top-left corner - let top_left = &grid[0][0]; - assert_eq!(top_left.r, ((500 - 1) % 256) as u8); - assert_eq!(top_left.g, ((500 - 1) % 256) as u8); - - // Top-right corner - let top_right = &grid[0][2]; - assert_eq!(top_right.r, ((500 + 1) % 256) as u8); - assert_eq!(top_right.g, ((500 - 1) % 256) as u8); - - // Bottom-left corner - let bottom_left = &grid[2][0]; - assert_eq!(bottom_left.r, ((500 - 1) % 256) as u8); - assert_eq!(bottom_left.g, ((500 + 1) % 256) as u8); -} diff --git a/electron-app/magnifier/rust-sampler/tests/sampler_tests.rs b/electron-app/magnifier/rust-sampler/tests/sampler_tests.rs deleted file mode 100644 index dc0fc0f75..000000000 --- a/electron-app/magnifier/rust-sampler/tests/sampler_tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -// Sampler selection and platform detection tests - -#[test] -fn test_platform_specific_compilation() { - // Ensure exactly one platform is active - let mut active_platforms = 0; - - #[cfg(target_os = "macos")] - { - active_platforms += 1; - } - - #[cfg(target_os = "linux")] - { - active_platforms += 1; - } - - #[cfg(target_os = "windows")] - { - active_platforms += 1; - } - - assert_eq!( - active_platforms, 1, - "Exactly one platform should be compiled" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn test_macos_sampler_available() { - assert!(true, "macOS platform detected"); -} - -#[cfg(target_os = "windows")] -#[test] -fn test_windows_sampler_available() { - assert!(true, "Windows platform detected"); -} - -#[cfg(target_os = "linux")] -#[test] -fn test_linux_platform_detected() { - assert_eq!(std::env::consts::OS, "linux"); -} - -#[test] -fn test_feature_flags_compilation() { - // Test that feature flags work correctly - #[cfg(feature = "wayland")] - { - assert!(true, "Wayland feature is enabled"); - } - - #[cfg(feature = "x11")] - { - assert!(true, "X11 feature is enabled"); - } - - // At least one should be true when running tests - #[cfg(not(any(feature = "wayland", feature = "x11")))] - { - // It's okay if neither is enabled - tests can still run - assert!(true, "No features enabled"); - } -} diff --git a/electron-app/magnifier/rust-sampler/tests/types_tests.rs b/electron-app/magnifier/rust-sampler/tests/types_tests.rs deleted file mode 100644 index 898568803..000000000 --- a/electron-app/magnifier/rust-sampler/tests/types_tests.rs +++ /dev/null @@ -1,199 +0,0 @@ -use swach_sampler::types::{Color, ColorData, Command, PixelData, PixelSampler, Point}; - -#[test] -fn test_color_creation() { - let color = Color::new(255, 128, 64); - assert_eq!(color.r, 255); - assert_eq!(color.g, 128); - assert_eq!(color.b, 64); -} - -#[test] -fn test_color_hex_string() { - let color = Color::new(255, 128, 64); - assert_eq!(color.hex_string(), "#FF8040"); -} - -#[test] -fn test_color_hex_all_zeros() { - let color = Color::new(0, 0, 0); - assert_eq!(color.hex_string(), "#000000"); -} - -#[test] -fn test_color_hex_all_ones() { - let color = Color::new(255, 255, 255); - assert_eq!(color.hex_string(), "#FFFFFF"); -} - -#[test] -fn test_color_to_color_data() { - let color = Color::new(100, 150, 200); - let data: ColorData = color.into(); - assert_eq!(data.r, 100); - assert_eq!(data.g, 150); - assert_eq!(data.b, 200); - assert_eq!(data.hex, "#6496C8"); -} - -#[test] -fn test_point_creation() { - let point = Point { x: 100, y: 200 }; - assert_eq!(point.x, 100); - assert_eq!(point.y, 200); -} - -#[test] -fn test_point_negative_coords() { - let point = Point { x: -50, y: -100 }; - assert_eq!(point.x, -50); - assert_eq!(point.y, -100); -} - -#[test] -fn test_command_start_deserialization() { - let json = r#"{"command":"start","grid_size":9,"sample_rate":20}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Start { grid_size, sample_rate } => { - assert_eq!(grid_size, 9); - assert_eq!(sample_rate, 20); - } - _ => panic!("Expected Start command"), - } -} - -#[test] -fn test_command_update_grid_deserialization() { - let json = r#"{"command":"update_grid","grid_size":15}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::UpdateGrid { grid_size } => { - assert_eq!(grid_size, 15); - } - _ => panic!("Expected UpdateGrid command"), - } -} - -#[test] -fn test_command_stop_deserialization() { - let json = r#"{"command":"stop"}"#; - let cmd: Command = serde_json::from_str(json).unwrap(); - match cmd { - Command::Stop => {} - _ => panic!("Expected Stop command"), - } -} - -#[test] -fn test_pixel_data_serialization() { - let pixel_data = PixelData { - cursor: Point { x: 100, y: 200 }, - center: ColorData { - r: 255, - g: 128, - b: 64, - hex: "#FF8040".to_string(), - }, - grid: vec![vec![ - ColorData { - r: 0, - g: 0, - b: 0, - hex: "#000000".to_string(), - }, - ColorData { - r: 255, - g: 255, - b: 255, - hex: "#FFFFFF".to_string(), - }, - ]], - timestamp: 1234567890, - }; - - let json = serde_json::to_string(&pixel_data).unwrap(); - assert!(json.contains("\"x\":100")); - assert!(json.contains("\"y\":200")); - assert!(json.contains("\"r\":255")); - assert!(json.contains("#FF8040")); -} - -// Mock sampler for testing the default grid implementation -struct MockSampler { - width: i32, - height: i32, -} - -impl PixelSampler for MockSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - if x < 0 || y < 0 || x >= self.width || y >= self.height { - Ok(Color::new(128, 128, 128)) // Gray for out of bounds - } else { - // Return a color based on position for testing - Ok(Color::new(x as u8, y as u8, 0)) - } - } - - fn get_cursor_position(&self) -> Result { - Ok(Point { x: 100, y: 100 }) - } -} - -#[test] -fn test_sample_grid_default_implementation() { - let mut sampler = MockSampler { - width: 1000, - height: 1000, - }; - - let grid = sampler.sample_grid(100, 100, 3, 1.0).unwrap(); - - // Should be 3x3 - assert_eq!(grid.len(), 3); - assert_eq!(grid[0].len(), 3); - - // Center should be at (100, 100) - let center = &grid[1][1]; - assert_eq!(center.r, 100); - assert_eq!(center.g, 100); -} - -#[test] -fn test_sample_grid_odd_size() { - let mut sampler = MockSampler { - width: 1000, - height: 1000, - }; - - let grid = sampler.sample_grid(50, 50, 5, 1.0).unwrap(); - - assert_eq!(grid.len(), 5); - assert_eq!(grid[0].len(), 5); - - // Center of 5x5 grid should be at index [2][2] - let center = &grid[2][2]; - assert_eq!(center.r, 50); - assert_eq!(center.g, 50); -} - -#[test] -fn test_sample_grid_bounds_checking() { - let mut sampler = MockSampler { - width: 10, - height: 10, - }; - - // Sample at origin (0, 0) with 3x3 grid - // This will sample from (-1,-1) to (1,1) - let grid = sampler.sample_grid(0, 0, 3, 1.0).unwrap(); - - // Top-left corner at (-1, -1) should be out of bounds (gray) - assert_eq!(grid[0][0].r, 128); - assert_eq!(grid[0][0].g, 128); - assert_eq!(grid[0][0].b, 128); - - // Center at (0, 0) should be valid (returns x=0, y=0) - assert_eq!(grid[1][1].r, 0); - assert_eq!(grid[1][1].g, 0); -} diff --git a/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs b/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs deleted file mode 100644 index 80bc051ac..000000000 --- a/electron-app/magnifier/rust-sampler/tests/windows_sampler_tests.rs +++ /dev/null @@ -1,767 +0,0 @@ -// Windows-specific sampler tests -// Only compiled and run on Windows - -#![cfg(target_os = "windows")] - -use swach_sampler::types::{Color, PixelSampler, Point}; - -// Mock Windows sampler for testing logic without requiring actual Windows APIs -struct MockWindowsSampler { - screen_width: i32, // Physical screen width (e.g., 5120 at 200% DPI) - screen_height: i32, // Physical screen height (e.g., 2880 at 200% DPI) - dpi_scale: f64, // DPI scale factor (e.g., 2.0 for 200%) -} - -impl MockWindowsSampler { - fn new(width: i32, height: i32) -> Self { - MockWindowsSampler { - screen_width: width, - screen_height: height, - dpi_scale: 1.0, // 100% scaling by default - } - } - - fn new_with_dpi(physical_width: i32, physical_height: i32, dpi_scale: f64) -> Self { - MockWindowsSampler { - screen_width: physical_width, - screen_height: physical_height, - dpi_scale, - } - } -} - -impl PixelSampler for MockWindowsSampler { - fn sample_pixel(&mut self, x: i32, y: i32) -> Result { - // With DPI awareness enabled, follow the macOS pattern: - // - x, y are logical coordinates - // - Convert to physical coordinates internally for sampling - let physical_x = (x as f64 * self.dpi_scale) as i32; - let physical_y = (y as f64 * self.dpi_scale) as i32; - - // screen_width/height are physical dimensions - if physical_x < 0 || physical_y < 0 || physical_x >= self.screen_width || physical_y >= self.screen_height { - return Err(format!("Failed to get pixel at ({}, {})", x, y)); - } - - // Simulate COLORREF BGR format conversion to RGB - // Use physical coordinates for color calculation (what GetPixel sees) - let b_component = (physical_x % 256) as u8; - let g_component = (physical_y % 256) as u8; - let r_component = ((physical_x + physical_y) % 256) as u8; - - Ok(Color::new(r_component, g_component, b_component)) - } - - fn get_cursor_position(&self) -> Result { - // With DPI awareness, GetCursorPos returns physical coordinates - // But we return logical coordinates (physical / dpi_scale) for Electron - let physical_x = 200; // Simulated physical cursor position - let physical_y = 200; - let logical_x = (physical_x as f64 / self.dpi_scale) as i32; - let logical_y = (physical_y as f64 / self.dpi_scale) as i32; - Ok(Point { x: logical_x, y: logical_y }) - } - - // Override sample_grid to simulate production behavior (logical coordinates) - fn sample_grid(&mut self, center_x: i32, center_y: i32, grid_size: usize, _scale_factor: f64) -> Result>, String> { - let half_size = (grid_size / 2) as i32; - let mut grid = Vec::with_capacity(grid_size); - - // Production sample_grid operates in logical coordinates like macOS - for row in 0..grid_size { - let mut row_pixels = Vec::with_capacity(grid_size); - for col in 0..grid_size { - // Calculate logical pixel coordinates (matches production behavior) - let logical_x = center_x + (col as i32 - half_size); - let logical_y = center_y + (row as i32 - half_size); - - // Convert logical to physical for bounds checking and color calculation - // (since screen_width/screen_height are physical dimensions) - let physical_x = (logical_x as f64 * self.dpi_scale) as i32; - let physical_y = (logical_y as f64 * self.dpi_scale) as i32; - - // Sample in physical space - if physical_x < 0 || physical_y < 0 || physical_x >= self.screen_width || physical_y >= self.screen_height { - row_pixels.push(Color::new(128, 128, 128)); - } else { - let b_component = (physical_x % 256) as u8; - let g_component = (physical_y % 256) as u8; - let r_component = ((physical_x + physical_y) % 256) as u8; - row_pixels.push(Color::new(r_component, g_component, b_component)); - } - } - grid.push(row_pixels); - } - - Ok(grid) - } -} - -#[test] -fn test_windows_sampler_basic_sampling() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let _color = sampler.sample_pixel(100, 200).unwrap(); - - // Colors are u8, so they're always in valid range (0-255) - // Just verify we got a color successfully -} - -#[test] -fn test_windows_sampler_error_on_negative_coords() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Windows GetPixel returns CLR_INVALID for out-of-bounds - let result = sampler.sample_pixel(-10, -10); - assert!(result.is_err(), "Should fail for negative coordinates"); -} - -#[test] -fn test_windows_sampler_error_on_out_of_bounds() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let result = sampler.sample_pixel(2000, 1100); - assert!(result.is_err(), "Should fail for coordinates beyond screen"); -} - -#[test] -fn test_windows_sampler_cursor_position() { - let sampler = MockWindowsSampler::new(1920, 1080); - - let cursor = sampler.get_cursor_position().unwrap(); - // With DPI awareness, physical 200 / scale 1.0 = logical 200 - assert_eq!(cursor.x, 200); - assert_eq!(cursor.y, 200); -} - -#[test] -fn test_windows_sampler_screen_boundaries() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Test at screen edges (valid) - let _color = sampler.sample_pixel(0, 0).unwrap(); - - let _color2 = sampler.sample_pixel(1919, 1079).unwrap(); - - // Test just outside screen (invalid) - assert!(sampler.sample_pixel(1920, 1080).is_err()); -} - -#[test] -fn test_windows_sampler_grid_sampling() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Test grid sampling uses default implementation - let grid = sampler.sample_grid(500, 500, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); - assert_eq!(grid[0].len(), 5); -} - -#[test] -fn test_windows_sampler_grid_with_partial_oob() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Sample near edge where some pixels will be out of bounds - // Center at (0, 0) with 3x3 grid samples from (-1,-1) to (1,1) - let grid = sampler.sample_grid(0, 0, 3, 1.0).unwrap(); - assert_eq!(grid.len(), 3); - - // Top-left pixel at (-1, -1) should be OOB and return gray fallback - let top_left = &grid[0][0]; - assert_eq!(top_left.r, 128); - assert_eq!(top_left.g, 128); - assert_eq!(top_left.b, 128); - - // Center pixel at (0, 0) should be valid - let center = &grid[1][1]; - assert_eq!(center.r, 0); - assert_eq!(center.g, 0); -} - -#[test] -fn test_windows_sampler_colorref_format() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Test that BGR to RGB conversion works correctly - let _color = sampler.sample_pixel(255, 128).unwrap(); - - // Verify hex string is in RGB format - let hex = _color.hex_string(); - assert!(hex.starts_with('#')); - assert_eq!(hex.len(), 7); - - // Should be uppercase hex - assert!(hex.chars().skip(1).all(|c| c.is_ascii_hexdigit() && !c.is_lowercase())); -} - -#[test] -fn test_windows_sampler_hdc_simulation() { - // Test that sampler can be created and used multiple times - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Simulate multiple samples (HDC should remain valid) - for _ in 0..100 { - let result = sampler.sample_pixel(500, 500); - assert!(result.is_ok(), "HDC should remain valid across samples"); - } -} - -#[test] -fn test_windows_sampler_large_coordinates() { - // Test 4K resolution - let mut sampler = MockWindowsSampler::new(3840, 2160); - - let _color = sampler.sample_pixel(3839, 2159).unwrap(); - - // Just outside should fail - assert!(sampler.sample_pixel(3840, 2160).is_err()); -} - -#[test] -fn test_windows_sampler_various_resolutions() { - // Test common Windows resolutions - let resolutions = [ - (1366, 768), // Common laptop - (1920, 1080), // Full HD - (2560, 1440), // QHD - (3840, 2160), // 4K UHD - ]; - - for (width, height) in resolutions { - let mut sampler = MockWindowsSampler::new(width, height); - - // Sample from center - let x = width / 2; - let y = height / 2; - let result = sampler.sample_pixel(x, y); - assert!(result.is_ok(), "Should work for {}x{}", width, height); - } -} - -#[test] -fn test_windows_sampler_grid_sizes() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Test all supported odd grid sizes - for size in [5, 7, 9, 11, 13, 15, 17, 19, 21] { - let grid = sampler.sample_grid(960, 540, size, 1.0).unwrap(); - assert_eq!(grid.len(), size, "Grid should be {}x{}", size, size); - assert_eq!(grid[0].len(), size, "Grid should be {}x{}", size, size); - } -} - -#[test] -fn test_windows_sampler_color_range() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Sample multiple points - colors are u8 so always in 0-255 range - for x in (0..1920).step_by(100) { - for y in (0..1080).step_by(100) { - let _color = sampler.sample_pixel(x, y).unwrap(); - // Successfully got a color, that's all we need to verify - } - } -} - -#[test] -fn test_windows_sampler_grid_center_alignment() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let center_x = 500; - let center_y = 500; - let grid_size = 9; - - let grid = sampler.sample_grid(center_x, center_y, grid_size, 1.0).unwrap(); - - // Center pixel should be at grid[4][4] for a 9x9 grid - let center_idx = grid_size / 2; - let center_pixel = &grid[center_idx][center_idx]; - - // The center pixel should correspond to (center_x, center_y) - let expected_color = sampler.sample_pixel(center_x, center_y).unwrap(); - assert_eq!(center_pixel.r, expected_color.r); - assert_eq!(center_pixel.g, expected_color.g); - assert_eq!(center_pixel.b, expected_color.b); -} - -#[test] -fn test_windows_sampler_optimized_grid_sampling() { - // Verify that the optimized implementation is being used - // This test is mostly for documentation purposes - the real test - // happens on actual Windows hardware - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let grid = sampler.sample_grid(500, 500, 9, 1.0).unwrap(); - - // Should return a valid 9x9 grid - assert_eq!(grid.len(), 9); - for row in &grid { - assert_eq!(row.len(), 9); - } -} - -#[test] -fn test_windows_sampler_grid_performance_large() { - // Test larger grid sizes that would be prohibitively slow with GetPixel - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Test 9x9 (81 pixels) - let grid = sampler.sample_grid(500, 500, 9, 1.0).unwrap(); - assert_eq!(grid.len(), 9); - - // Test 11x11 (121 pixels) - let grid = sampler.sample_grid(500, 500, 11, 1.0).unwrap(); - assert_eq!(grid.len(), 11); - - // Test 15x15 (225 pixels) - let grid = sampler.sample_grid(500, 500, 15, 1.0).unwrap(); - assert_eq!(grid.len(), 15); - - // Test 21x21 (441 pixels) - let grid = sampler.sample_grid(500, 500, 21, 1.0).unwrap(); - assert_eq!(grid.len(), 21); -} - -#[test] -fn test_windows_sampler_grid_pixel_alignment() { - // Verify that pixels in the grid match individual pixel samples - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let center_x = 500; - let center_y = 500; - let grid_size = 5; - - let grid = sampler.sample_grid(center_x, center_y, grid_size, 1.0).unwrap(); - - // Check all pixels in the grid match individual samples - let half_size = (grid_size / 2) as i32; - for row in 0..grid_size { - for col in 0..grid_size { - let x = center_x + (col as i32 - half_size); - let y = center_y + (row as i32 - half_size); - - let grid_color = &grid[row][col]; - let individual_color = sampler.sample_pixel(x, y).unwrap(); - - assert_eq!(grid_color.r, individual_color.r, "Mismatch at ({}, {})", x, y); - assert_eq!(grid_color.g, individual_color.g, "Mismatch at ({}, {})", x, y); - assert_eq!(grid_color.b, individual_color.b, "Mismatch at ({}, {})", x, y); - } - } -} - -#[test] -fn test_windows_sampler_grid_edge_cases() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Test near screen edges - // Top-left corner - let grid = sampler.sample_grid(10, 10, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); - - // Bottom-right corner (within bounds) - let grid = sampler.sample_grid(1910, 1070, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); - - // Top edge - let grid = sampler.sample_grid(500, 5, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); - - // Right edge - let grid = sampler.sample_grid(1915, 500, 5, 1.0).unwrap(); - assert_eq!(grid.len(), 5); -} - -#[test] -fn test_windows_sampler_grid_multi_monitor() { - // Simulate extended desktop spanning multiple monitors - // Windows treats this as one large virtual screen - let mut sampler = MockWindowsSampler::new(3840, 1080); // Two 1920x1080 monitors - - // Sample from "first monitor" - let grid1 = sampler.sample_grid(500, 500, 9, 1.0).unwrap(); - assert_eq!(grid1.len(), 9); - - // Sample from "second monitor" - let grid2 = sampler.sample_grid(2500, 500, 9, 1.0).unwrap(); - assert_eq!(grid2.len(), 9); - - // Sample at boundary between monitors - let grid3 = sampler.sample_grid(1920, 500, 9, 1.0).unwrap(); - assert_eq!(grid3.len(), 9); -} - -#[test] -fn test_windows_sampler_grid_high_dpi() { - // Test high DPI scenarios (e.g., 150% scaling, 200% scaling) - // The sampler should work with physical pixels regardless of DPI - let mut sampler = MockWindowsSampler::new(2560, 1440); - - let grid = sampler.sample_grid(1280, 720, 9, 1.0).unwrap(); - assert_eq!(grid.len(), 9); - - // Test 4K resolution (common with 150% or 200% scaling) - let mut sampler_4k = MockWindowsSampler::new(3840, 2160); - let grid_4k = sampler_4k.sample_grid(1920, 1080, 9, 1.0).unwrap(); - assert_eq!(grid_4k.len(), 9); -} - -#[test] -fn test_windows_sampler_grid_fully_oob() { - // Test grid completely out of bounds - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Center way outside screen bounds - let grid = sampler.sample_grid(-1000, -1000, 5, 1.0).unwrap(); - - // Should return gray fallback for all pixels - for row in &grid { - for pixel in row { - assert_eq!(pixel.r, 128); - assert_eq!(pixel.g, 128); - assert_eq!(pixel.b, 128); - } - } -} - -#[test] -fn test_windows_sampler_grid_color_accuracy() { - // Verify colors are correctly converted from BGR to RGB - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let grid = sampler.sample_grid(255, 128, 3, 1.0).unwrap(); - - // All colors should be valid (0-255 range) - for row in &grid { - for pixel in row { - // Colors are u8, so they're always in valid range - // Just verify we got actual color data - let hex = pixel.hex_string(); - assert_eq!(hex.len(), 7); - assert!(hex.starts_with('#')); - } - } -} - -#[test] -fn test_windows_sampler_grid_consistency() { - // Test that multiple samples of the same region return consistent results - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let center_x = 500; - let center_y = 500; - - let grid1 = sampler.sample_grid(center_x, center_y, 7, 1.0).unwrap(); - let grid2 = sampler.sample_grid(center_x, center_y, 7, 1.0).unwrap(); - - // Grids should be identical - for row in 0..7 { - for col in 0..7 { - assert_eq!(grid1[row][col].r, grid2[row][col].r); - assert_eq!(grid1[row][col].g, grid2[row][col].g); - assert_eq!(grid1[row][col].b, grid2[row][col].b); - } - } -} - -// ============================================================================ -// DPI Scaling Tests -// ============================================================================ - -#[test] -fn test_windows_sampler_dpi_100_percent() { - // Test 100% DPI scaling (no scaling) - let mut sampler = MockWindowsSampler::new_with_dpi(1920, 1080, 1.0); - - // Physical coordinate 1000 should map to logical 1000 - let color = sampler.sample_pixel(1000, 500).unwrap(); - - // Color should be based on logical coordinates (1000, 500) - assert_eq!(color.b, (1000 % 256) as u8); - assert_eq!(color.g, (500 % 256) as u8); -} - -#[test] -fn test_windows_sampler_dpi_150_percent() { - // Test 150% DPI scaling (1.5x) - // Physical screen: 2880x1620, Virtual screen: 1920x1080 - let mut sampler = MockWindowsSampler::new_with_dpi(2880, 1620, 1.5); - - // Virtual coordinate 1000 should map to physical 1500 (1000 * 1.5) - let color = sampler.sample_pixel(1000, 500).unwrap(); - - // Color should be based on physical coordinates (1500, 750) - assert_eq!(color.b, (1500 % 256) as u8); - assert_eq!(color.g, (750 % 256) as u8); -} - -#[test] -fn test_windows_sampler_dpi_200_percent() { - // Test 200% DPI scaling (2x) - the reported issue - // Physical screen: 5120x2880, Logical screen: 2560x1440 - let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - - // Logical coordinate 1000 should map to physical 2000 (1000 * 2.0) internally - let color = sampler.sample_pixel(1000, 500).unwrap(); - - // Color should be based on physical coordinates (2000, 1000) - assert_eq!(color.b, (2000 % 256) as u8); - assert_eq!(color.g, (1000 % 256) as u8); -} - -#[test] -fn test_windows_sampler_dpi_coordinate_conversion() { - // Test that DPI scaling correctly converts coordinates - // Physical screen: 5120x2880 - let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - - // Test various virtual->physical conversions at 200% DPI - let test_cases = vec![ - (0, 0, 0, 0), // Origin - (50, 100, 100, 200), // Small coordinates - (500, 250, 1000, 500), // Medium coordinates - (1000, 500, 2000, 1000),// Large coordinates - (2500, 1400, 5000, 2800),// Near max (2560x1440 virtual -> 5120x2880 physical) - ]; - - for (virtual_x, virtual_y, expected_physical_x, expected_physical_y) in test_cases { - let color = sampler.sample_pixel(virtual_x, virtual_y).unwrap(); - - // Verify color matches expected physical coordinates - let expected_b = (expected_physical_x % 256) as u8; - let expected_g = (expected_physical_y % 256) as u8; - - assert_eq!( - color.b, expected_b, - "Virtual ({}, {}) should map to physical ({}, {}) at 200% DPI", - virtual_x, virtual_y, expected_physical_x, expected_physical_y - ); - assert_eq!( - color.g, expected_g, - "Virtual ({}, {}) should map to physical ({}, {}) at 200% DPI", - virtual_x, virtual_y, expected_physical_x, expected_physical_y - ); - } -} - -#[test] -fn test_windows_sampler_dpi_grid_sampling_200_percent() { - // Test grid sampling at 200% DPI - // Physical: 5120x2880 - let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - - // Virtual cursor at 1000, 500 should map to physical 2000, 1000 - let grid = sampler.sample_grid(1000, 500, 5, 1.0).unwrap(); - - assert_eq!(grid.len(), 5); - - // Center pixel should be at physical coordinates (2000, 1000) - let center = &grid[2][2]; - assert_eq!(center.b, (2000 % 256) as u8); - assert_eq!(center.g, (1000 % 256) as u8); -} - -#[test] -fn test_windows_sampler_dpi_5120x2880_display() { - // Test actual 5120x2880 display at 200% DPI (user's reported issue) - // Physical: 5120x2880, Virtual: 2560x1440 - let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - - // Cursor in the middle of virtual screen: 1280, 720 - // Should map to physical: 2560, 1440 - let grid = sampler.sample_grid(1280, 720, 9, 1.0).unwrap(); - - assert_eq!(grid.len(), 9); - - // Center pixel should be at physical coordinates (2560, 1440) - let center = &grid[4][4]; - assert_eq!(center.b, (2560 % 256) as u8); - assert_eq!(center.g, (1440 % 256) as u8); -} - -#[test] -fn test_windows_sampler_dpi_offset_bug() { - // Reproduce the reported bug: 500+ pixel offset at 200% DPI - // Physical: 5120x2880 - let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - - // Without DPI scaling fix, virtual 1000 would be treated as physical 1000 - // With DPI scaling fix, virtual 1000 maps to physical 2000 - // The difference is 1000 pixels (500 in each direction on screen at 200%) - - let virtual_x = 1000; - let expected_physical_x = 2000; // virtual_x * 2.0 - - let color = sampler.sample_pixel(virtual_x, 500).unwrap(); - - // Color should be based on physical coordinates (2000, 1000) - assert_eq!(color.b, (expected_physical_x % 256) as u8); - assert_eq!(color.g, (1000 % 256) as u8); -} - -#[test] -fn test_windows_sampler_dpi_various_scales() { - // Test common Windows DPI scaling values - let test_cases = vec![ - (1920, 1080, 1.0, "100%"), // No scaling - (2400, 1350, 1.25, "125%"), // 125% of 1920x1080 - (2880, 1620, 1.5, "150%"), // 150% of 1920x1080 - (3360, 1890, 1.75, "175%"), // 175% of 1920x1080 - (3840, 2160, 2.0, "200%"), // 200% of 1920x1080 (4K) - (4800, 2700, 2.5, "250%"), // 250% of 1920x1080 - (5760, 3240, 3.0, "300%"), // 300% of 1920x1080 - ]; - - for (physical_width, physical_height, scale, description) in test_cases { - let mut sampler = MockWindowsSampler::new_with_dpi(physical_width, physical_height, scale); - - let virtual_x = 800; - let expected_physical_x = (virtual_x as f64 * scale) as i32; - - let color = sampler.sample_pixel(virtual_x, 400).unwrap(); - - // Verify coordinate conversion works for this scale - let expected_b = (expected_physical_x % 256) as u8; - assert_eq!( - color.b, expected_b, - "DPI scaling {} failed: virtual {} should map to physical {}", - description, virtual_x, expected_physical_x - ); - } -} - -#[test] -fn test_windows_sampler_dpi_out_of_bounds() { - // Test that out-of-bounds checking works with DPI scaling - let mut sampler = MockWindowsSampler::new_with_dpi(2560, 1440, 2.0); - - // Physical coordinate 6000 maps to logical 3000, which is > 2560 - let result = sampler.sample_pixel(6000, 3000); - assert!(result.is_err(), "Should fail for out-of-bounds coordinates"); -} - -#[test] -fn test_windows_sampler_dpi_fallback_no_duplicates() { - // Test that the fallback method doesn't produce duplicate samples at high DPI - // This was a bug where physical pixel offsets caused logical pixel duplicates - let mut sampler = MockWindowsSampler::new_with_dpi(2560, 1440, 2.0); - - // Use the fallback implementation (default trait implementation) - let physical_center_x = 1000; - let physical_center_y = 500; - let grid_size = 9; - - // Get grid using default implementation (simulates fallback) - let grid = sampler.sample_grid(physical_center_x, physical_center_y, grid_size, 1.0).unwrap(); - - // At 200% DPI, we should get 9 distinct logical pixels, not duplicates - // Physical 992-1008 should map to logical 496-504 (9 distinct values) - - // Collect center row colors to check for uniqueness - let mut center_row_colors: Vec<(u8, u8, u8)> = Vec::new(); - for col in 0..grid_size { - let color = &grid[4][col]; // Center row - center_row_colors.push((color.r, color.g, color.b)); - } - - // Check that we don't have adjacent duplicates (which would indicate the bug) - for i in 1..center_row_colors.len() { - assert_ne!( - center_row_colors[i], center_row_colors[i - 1], - "Found duplicate colors at indices {} and {} - DPI fallback bug!", - i - 1, i - ); - } -} - -#[test] -fn test_windows_sampler_dpi_grid_edge_alignment() { - // Test that grid pixels correctly sample physical pixels at 200% DPI - // Physical: 5120x2880, Virtual: 2560x1440 - let mut sampler = MockWindowsSampler::new_with_dpi(5120, 2880, 2.0); - - let virtual_center_x = 1000; - let virtual_center_y = 500; - let grid_size = 5; - - let grid = sampler.sample_grid(virtual_center_x, virtual_center_y, grid_size, 1.0).unwrap(); - - // Verify the grid has correct dimensions - assert_eq!(grid.len(), grid_size); - assert_eq!(grid[0].len(), grid_size); - - // Verify center pixel matches what we expect - // Mock sample_grid operates in virtual coordinates like production - let center_idx = grid_size / 2; // 2 for a 5x5 grid - let center_pixel = &grid[center_idx][center_idx]; - - // Center samples at virtual position (1000, 500) -> physical (2000, 1000) - // Colors are based on physical coordinates - let expected_b = (2000 % 256) as u8; // 2000 % 256 = 224 - let expected_g = (1000 % 256) as u8; // 1000 % 256 = 232 - let expected_r = ((2000 + 1000) % 256) as u8; // 3000 % 256 = 200 - - assert_eq!(center_pixel.r, expected_r, "Center pixel R component mismatch"); - assert_eq!(center_pixel.g, expected_g, "Center pixel G component mismatch"); - assert_eq!(center_pixel.b, expected_b, "Center pixel B component mismatch"); - - // Grid samples at virtual offsets from center (1000, 500) - // Virtual half_size = 2 for 5x5 grid - // Top-left: virtual (998, 498) -> physical (1996, 996) - let top_left = &grid[0][0]; - assert_eq!(top_left.b, (1996 % 256) as u8); // 1996 % 256 = 220 - assert_eq!(top_left.g, (996 % 256) as u8); // 996 % 256 = 228 - - // Bottom-right: virtual (1002, 502) -> physical (2004, 1004) - let bottom_right = &grid[4][4]; - assert_eq!(bottom_right.b, (2004 % 256) as u8); // 2004 % 256 = 228 - assert_eq!(bottom_right.g, (1004 % 256) as u8); // 1004 % 256 = 236 -} - -#[test] -fn test_windows_sampler_multi_monitor_simulation() { - // Simulate extended desktop spanning multiple monitors - // Windows treats this as one large virtual screen - let mut sampler = MockWindowsSampler::new(3840, 1080); // Two 1920x1080 monitors - - // Sample from "second monitor" - let _color = sampler.sample_pixel(2500, 500).unwrap(); -} - -#[test] -fn test_windows_sampler_high_dpi_scaling() { - // Windows high DPI scaling test - // The sampler should work with physical pixels - let mut sampler = MockWindowsSampler::new(2560, 1440); - - let grid = sampler.sample_grid(1280, 720, 7, 1.0).unwrap(); - assert_eq!(grid.len(), 7); -} - -#[test] -fn test_windows_sampler_rapid_sampling() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - // Simulate rapid sampling like in the actual magnifier - let mut samples = Vec::new(); - for i in 0..50 { - let x = 500 + i; - let y = 500 + i; - let color = sampler.sample_pixel(x, y).unwrap(); - samples.push(color); - } - - assert_eq!(samples.len(), 50); -} - -#[test] -fn test_windows_sampler_error_messages() { - let mut sampler = MockWindowsSampler::new(1920, 1080); - - let result = sampler.sample_pixel(-1, -1); - assert!(result.is_err()); - - let err_msg = result.unwrap_err(); - assert!(err_msg.contains("Failed to get pixel")); - assert!(err_msg.contains("-1")); -} diff --git a/electron-app/magnifier/styles.css b/electron-app/magnifier/styles.css deleted file mode 100644 index c640a8acc..000000000 --- a/electron-app/magnifier/styles.css +++ /dev/null @@ -1,36 +0,0 @@ -@import 'tailwindcss'; - -@layer base { - body { - @apply font-sans; - } -} - -/* Custom styles that can't be easily converted to Tailwind classes */ -.pixel { - @apply transition-colors duration-100 ease-out outline-1 outline-neutral-500/40 -outline-offset-1; -} - -.pixel.center { - @apply border-2 border-white relative z-10; - box-shadow: - 0 0 6px #000, - inset 0 0 4px rgb(255 255 255 / 80%); -} - -/* Fix magnifier circle zoom animations */ -.magnifier-circle { - @apply duration-300 ease-out; - transition: - width 0.3s cubic-bezier(0.4, 0, 0.2, 1), - height 0.3s cubic-bezier(0.4, 0, 0.2, 1), - min-width 0.3s cubic-bezier(0.4, 0, 0.2, 1), - min-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), - max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1), - max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; -} - -/* Fix color label transition */ -#color-label { - @apply transition-all duration-200 ease-out; -} diff --git a/electron-app/magnifier/types.ts b/electron-app/magnifier/types.ts deleted file mode 100644 index d282ed100..000000000 --- a/electron-app/magnifier/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Derived types from the actual magnifier API implementation -// This ensures types stay in sync with the actual implementation - -import type { magnifierAPI } from './magnifier-preload'; - -// Export the main API type derived from the implementation -export type MagnifierAPI = typeof magnifierAPI; - -// Extract parameter types from the callback functions for convenience -type UpdatePositionCallback = Parameters< - typeof magnifierAPI.on.updatePosition ->[0]; -type UpdatePixelGridCallback = Parameters< - typeof magnifierAPI.on.updatePixelGrid ->[0]; - -// Derive the data types from the callback parameter types -export type PositionData = Parameters[0]; -export type PixelGridData = Parameters[0]; - -// Extract nested types for convenience -export type ColorData = PixelGridData['centerColor']; diff --git a/electron-app/magnifier/utils.test.ts b/electron-app/magnifier/utils.test.ts deleted file mode 100644 index ba2c9dd3e..000000000 --- a/electron-app/magnifier/utils.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { - adjustSquareSize, - AVAILABLE_DIAMETERS, - AVAILABLE_GRID_SIZES, - calculateOptimalGridSize, - cursorToImageCoordinates, - getCenterPixelIndex, - getNextDiameter, - MAX_SQUARE_SIZE, - MIN_SQUARE_SIZE, -} from './utils'; - -describe('magnifier-utils', () => { - describe('calculateOptimalGridSize', () => { - test('returns optimal grid size for exact divisions', () => { - expect(calculateOptimalGridSize(180, 20)).toBe(9); - expect(calculateOptimalGridSize(140, 20)).toBe(7); - expect(calculateOptimalGridSize(100, 20)).toBe(5); - }); - - test('returns closest odd grid size for non-exact divisions', () => { - expect(calculateOptimalGridSize(180, 18)).toBe(9); - expect(calculateOptimalGridSize(200, 20)).toBe(9); - }); - - test('always returns odd numbers', () => { - const result = calculateOptimalGridSize(180, 19); - expect(result % 2).toBe(1); - }); - - test('returns a value from AVAILABLE_GRID_SIZES', () => { - const result = calculateOptimalGridSize(250, 25); - expect(AVAILABLE_GRID_SIZES.includes(result)).toBe(true); - }); - }); - - describe('getNextDiameter', () => { - test('increments diameter when delta is positive', () => { - expect(getNextDiameter(180, 1)).toBe(210); - expect(getNextDiameter(240, 1)).toBe(270); - }); - - test('decrements diameter when delta is negative', () => { - expect(getNextDiameter(240, -1)).toBe(210); - expect(getNextDiameter(180, -1)).toBe(150); - }); - - test('clamps at minimum diameter', () => { - expect(getNextDiameter(120, -1)).toBe(120); - expect(getNextDiameter(120, -5)).toBe(120); - }); - - test('clamps at maximum diameter', () => { - expect(getNextDiameter(420, 1)).toBe(420); - expect(getNextDiameter(420, 5)).toBe(420); - }); - }); - - describe('adjustSquareSize', () => { - test('adjusts square size by delta * step', () => { - expect(adjustSquareSize(20, 1, 2)).toBe(22); - expect(adjustSquareSize(20, -1, 2)).toBe(18); - expect(adjustSquareSize(20, 2, 2)).toBe(24); - }); - - test('clamps at minimum square size', () => { - expect(adjustSquareSize(MIN_SQUARE_SIZE, -1, 2)).toBe(MIN_SQUARE_SIZE); - expect(adjustSquareSize(12, -5, 2)).toBe(MIN_SQUARE_SIZE); - }); - - test('clamps at maximum square size', () => { - expect(adjustSquareSize(MAX_SQUARE_SIZE, 1, 2)).toBe(MAX_SQUARE_SIZE); - expect(adjustSquareSize(38, 5, 2)).toBe(MAX_SQUARE_SIZE); - }); - - test('uses default step of 2', () => { - expect(adjustSquareSize(20, 1)).toBe(22); - expect(adjustSquareSize(20, -1)).toBe(18); - }); - }); - - describe('getCenterPixelIndex', () => { - test('returns center index for odd grid sizes', () => { - expect(getCenterPixelIndex(5)).toBe(12); - expect(getCenterPixelIndex(7)).toBe(24); - expect(getCenterPixelIndex(9)).toBe(40); - expect(getCenterPixelIndex(11)).toBe(60); - }); - - test('handles minimum grid size', () => { - expect(getCenterPixelIndex(5)).toBe(12); - }); - - test('handles maximum grid size', () => { - expect(getCenterPixelIndex(21)).toBe(220); - }); - }); - - describe('cursorToImageCoordinates', () => { - test('converts cursor position with 1x scale on primary display', () => { - const result = cursorToImageCoordinates(100, 200, 1, { x: 0, y: 0 }); - expect(result.imageX).toBe(100); - expect(result.imageY).toBe(200); - }); - - test('converts cursor position with 2x scale (Retina) on primary display', () => { - const result = cursorToImageCoordinates(100, 200, 2, { x: 0, y: 0 }); - expect(result.imageX).toBe(200); - expect(result.imageY).toBe(400); - }); - - test('rounds to nearest logical pixel', () => { - const result = cursorToImageCoordinates(100.6, 200.4, 2, { x: 0, y: 0 }); - expect(result.imageX).toBe(202); - expect(result.imageY).toBe(400); - }); - - test('handles fractional scale factors', () => { - const result = cursorToImageCoordinates(100, 100, 1.5, { x: 0, y: 0 }); - expect(result.imageX).toBe(150); - expect(result.imageY).toBe(150); - }); - - test('handles display bounds offset for secondary display', () => { - // Cursor at global position 1920, 100 on a secondary display with bounds at x: 1920, y: 0 - const result = cursorToImageCoordinates(1920, 100, 2, { x: 1920, y: 0 }); - expect(result.imageX).toBe(0); // Should be 0 relative to the display - expect(result.imageY).toBe(200); // 100 * 2 scale factor - }); - - test('handles display bounds offset for vertically stacked displays', () => { - // Cursor at global position 100, 1080 on a display with bounds at x: 0, y: 1080 - const result = cursorToImageCoordinates(100, 1080, 1, { x: 0, y: 1080 }); - expect(result.imageX).toBe(100); - expect(result.imageY).toBe(0); // Should be 0 relative to the display - }); - }); - - describe('constants', () => { - test('AVAILABLE_DIAMETERS is valid', () => { - expect(AVAILABLE_DIAMETERS.length).toBeGreaterThan(0); - expect(AVAILABLE_DIAMETERS).toEqual([ - 120, 150, 180, 210, 240, 270, 300, 330, 360, 390, 420, - ]); - }); - - test('AVAILABLE_GRID_SIZES contains only odd numbers', () => { - expect(AVAILABLE_GRID_SIZES.length).toBeGreaterThan(0); - AVAILABLE_GRID_SIZES.forEach((size: number) => { - expect(size % 2).toBe(1); - }); - }); - - test('square size constraints are valid', () => { - expect(MIN_SQUARE_SIZE).toBeLessThan(MAX_SQUARE_SIZE); - expect(MIN_SQUARE_SIZE).toBeGreaterThan(0); - }); - - test('all diameter/square size combinations produce valid grid sizes', () => { - // Test all combinations to ensure they work - const squareSizes = [MIN_SQUARE_SIZE, 20, 30, MAX_SQUARE_SIZE]; - const results: string[] = []; - - AVAILABLE_DIAMETERS.forEach((diameter) => { - squareSizes.forEach((squareSize) => { - const gridSize = calculateOptimalGridSize(diameter, squareSize); - expect(AVAILABLE_GRID_SIZES.includes(gridSize)).toBe(true); - results.push(`D${diameter}/S${squareSize}=G${gridSize}`); - }); - }); - - // Log for debugging - console.log('Diameter/Square combinations:', results.join(', ')); - }); - - test('max diameter with min square size should give largest grid', () => { - const maxDiameter = AVAILABLE_DIAMETERS[AVAILABLE_DIAMETERS.length - 1]!; - const gridSize = calculateOptimalGridSize(maxDiameter, MIN_SQUARE_SIZE); - - // 420 / 10 = 42, closest odd is 21 (max available) - expect(gridSize).toBe(21); - }); - - test('min diameter with max square size should give smallest grid', () => { - const minDiameter = AVAILABLE_DIAMETERS[0]!; - const gridSize = calculateOptimalGridSize(minDiameter, MAX_SQUARE_SIZE); - - // 120 / 40 = 3, closest available is 5 (min available) - expect(gridSize).toBe(5); - }); - }); -}); diff --git a/electron-app/magnifier/utils.ts b/electron-app/magnifier/utils.ts deleted file mode 100644 index f8af52bd0..000000000 --- a/electron-app/magnifier/utils.ts +++ /dev/null @@ -1,87 +0,0 @@ -export const AVAILABLE_DIAMETERS = [ - 120, 150, 180, 210, 240, 270, 300, 330, 360, 390, 420, -]; -export const AVAILABLE_GRID_SIZES = [5, 7, 9, 11, 13, 15, 17, 19, 21]; -export const MIN_SQUARE_SIZE = 10; -export const MAX_SQUARE_SIZE = 40; - -/** - * Calculate the optimal grid size for a given diameter and square size. - * Returns the closest available odd grid size that fits within the diameter. - */ -export function calculateOptimalGridSize( - diameter: number, - squareSize: number -): number { - const idealGridSize = Math.floor(diameter / squareSize); - const adjustedGridSize = - idealGridSize % 2 === 0 ? idealGridSize - 1 : idealGridSize; - - const closestGridSize = AVAILABLE_GRID_SIZES.reduce((prev, curr) => - Math.abs(curr - adjustedGridSize) < Math.abs(prev - adjustedGridSize) - ? curr - : prev - ); - - return closestGridSize; -} - -/** - * Get the next diameter in the available diameters list. - * Returns the current diameter if already at the boundary. - */ -export function getNextDiameter( - currentDiameter: number, - delta: number -): number { - const currentIndex = AVAILABLE_DIAMETERS.indexOf(currentDiameter); - const newIndex = Math.max( - 0, - Math.min(AVAILABLE_DIAMETERS.length - 1, currentIndex + delta) - ); - - return AVAILABLE_DIAMETERS[newIndex]!; -} - -/** - * Adjust square size within bounds by a given delta. - */ -export function adjustSquareSize( - currentSize: number, - delta: number, - step = 2 -): number { - return Math.max( - MIN_SQUARE_SIZE, - Math.min(MAX_SQUARE_SIZE, currentSize + delta * step) - ); -} - -/** - * Calculate the center pixel index for a given grid size. - * Grid sizes are always odd, so center is (size * size - 1) / 2 - */ -export function getCenterPixelIndex(gridSize: number): number { - return Math.floor((gridSize * gridSize) / 2); -} - -/** - * Convert cursor position to image coordinates accounting for display scaling and bounds offset. - */ -export function cursorToImageCoordinates( - cursorX: number, - cursorY: number, - scaleFactor: number, - displayBounds: { x: number; y: number } -): { imageX: number; imageY: number } { - // Convert global cursor coordinates to display-relative coordinates - const displayRelativeX = cursorX - displayBounds.x; - const displayRelativeY = cursorY - displayBounds.y; - - const logicalX = Math.round(displayRelativeX); - const logicalY = Math.round(displayRelativeY); - const imageX = logicalX * scaleFactor; - const imageY = logicalY * scaleFactor; - - return { imageX, imageY }; -} diff --git a/electron-app/magnifier/vitest.config.ts b/electron-app/magnifier/vitest.config.ts deleted file mode 100644 index 316eb22b9..000000000 --- a/electron-app/magnifier/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['**/*.test.ts'], - }, -}); diff --git a/electron-app/src/color-picker.ts b/electron-app/src/color-picker.ts index d63242de2..07a9a7f83 100644 --- a/electron-app/src/color-picker.ts +++ b/electron-app/src/color-picker.ts @@ -1,21 +1,70 @@ +import { colornames } from 'color-name-list'; +import { ColorPicker } from 'hue-hunter'; import { type Menubar } from 'menubar'; +import nearestColor from 'nearest-color'; -// Rust-based implementation (fast, works on macOS, Windows, Linux (X11, Wayland)). -import { launchMagnifyingColorPicker as launchRustPicker } from '../magnifier/magnifier-main-rust.js'; -// Electron-based implementation fallback for when the rust picker fails. -import { launchMagnifyingColorPicker as launchElectronPicker } from '../magnifier/magnifier-main.js'; +// Setup color name lookup function once +const namedColors = colornames.reduce( + ( + o: { [key: string]: string }, + { name, hex }: { name: string; hex: string } + ) => Object.assign(o, { [name]: hex }), + {} +); +const getColorName = nearestColor.from(namedColors) as ( + color: string | { r: number; g: number; b: number } +) => { + name: string; + value: string; + rgb: { r: number; g: number; b: number }; + distance: number; +}; + +// Create ColorPicker instance with color naming +const picker = new ColorPicker({ + colorNameFn: (rgb) => getColorName(rgb).name, + initialDiameter: 180, + initialSquareSize: 20, +}); async function launchPicker(mb: Menubar, type = 'global') { try { - await launchRustPicker(mb, type); - return; - } catch (error) { - console.warn( - '[Color Picker] Rust sampler failed, falling back to Electron implementation:', - error - ); + // Hide window and wait for it to be fully hidden + if (mb.window && !mb.window.isDestroyed()) { + const hidePromise = new Promise((resolve) => { + if (mb.window?.isVisible()) { + mb.window?.once('hide', () => resolve()); + mb.hideWindow(); + } else { + resolve(); + } + }); + await hidePromise; + } else { + mb.hideWindow(); + } - await launchElectronPicker(mb, type); + // Launch the color picker + const color = await picker.pickColor(); + + // Send the selected color to the renderer if one was chosen + if (color) { + if (mb.window && !mb.window.isDestroyed()) { + if (type === 'global') { + mb.window.webContents.send('changeColor', color); + } + if (type === 'contrastBg') { + mb.window.webContents.send('pickContrastBgColor', color); + } + if (type === 'contrastFg') { + mb.window.webContents.send('pickContrastFgColor', color); + } + } + } + } catch (error) { + console.error('[Color Picker] Failed to launch:', error); + } finally { + void mb.showWindow(); } } diff --git a/electron-app/tsconfig.json b/electron-app/tsconfig.json index ee1adc7be..24c50489b 100644 --- a/electron-app/tsconfig.json +++ b/electron-app/tsconfig.json @@ -24,7 +24,6 @@ }, "include": [ "main.ts", - "magnifier/**/*", "src/**/*", "tests/**/*", "../types/global.d.ts", diff --git a/eslint.config.mjs b/eslint.config.mjs index 747b7c03d..69f3f18a6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,7 +51,6 @@ export default ts.config( 'declarations/**/*', 'dist/**/*', 'electron-app/dist/**/*', - 'electron-app/magnifier/rust-sampler/target/**/*', 'node_modules/**/*', 'out/**/*', 'types/**/*', diff --git a/forge.config.ts b/forge.config.ts index 051186f00..02cce5669 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -56,12 +56,14 @@ const config: ForgeConfig = { // Include all resources in the packaged app extraResource: [ 'electron-app/resources', - // Conditionally include platform-specific Rust sampler binary + // Conditionally include platform-specific hue-hunter sampler binary ...(process.platform === 'win32' ? [ - 'electron-app/magnifier/rust-sampler/target/release/swach-sampler.exe', + 'node_modules/hue-hunter/rust-sampler/target/release/hue-hunter-sampler.exe', ] - : ['electron-app/magnifier/rust-sampler/target/release/swach-sampler']), + : [ + 'node_modules/hue-hunter/rust-sampler/target/release/hue-hunter-sampler', + ]), ], }, makers: [ @@ -113,20 +115,12 @@ const config: ForgeConfig = { entry: 'electron-app/src/preload.ts', config: 'vite.preload.config.ts', }, - { - entry: 'electron-app/magnifier/magnifier-preload.ts', - config: 'vite.preload.config.ts', - }, ], renderer: [ { name: 'main_window', config: 'vite.renderer.config.ts', }, - { - name: 'magnifier_window', - config: 'vite.magnifier.config.ts', - }, ], }), // Fuses are used to enable/disable various Electron functionality diff --git a/package.json b/package.json index 16717a5bc..0af42c32a 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,6 @@ "test": "tests" }, "scripts": { - "build:rust": "cd electron-app/magnifier/rust-sampler && cargo build --release --features x11,wayland", - "build:rust:ci": "cd electron-app/magnifier/rust-sampler && cargo build --release --features x11", - "build:rust:dev": "cd electron-app/magnifier/rust-sampler && cargo build --features x11,wayland", "format": "prettier . --cache --write", "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", "lint:css": "stylelint \"**/*.css\"", @@ -34,16 +31,13 @@ "lint:js": "eslint . --cache", "lint:js:fix": "eslint . --fix", "lint:types": "glint --build", - "make": "pnpm build:rust && cross-env EMBER_CLI_ELECTRON=true electron-forge make", - "package": "pnpm build:rust && cross-env EMBER_CLI_ELECTRON=true electron-forge package", + "make": "cross-env EMBER_CLI_ELECTRON=true electron-forge make", + "package": "cross-env EMBER_CLI_ELECTRON=true electron-forge package", "publish": "electron-forge publish", "start": "vite -c vite.renderer.config.ts", - "start:electron": "pnpm build:rust:dev && cross-env ELECTRON_DISABLE_SANDBOX=1 electron-forge start -- --no-sandbox --disable-gpu-sandbox --ozone-platform=x11", - "test:electron": "pnpm build:rust:ci && cross-env SKIP_CODESIGN=true electron-forge package && vite build -c vite.renderer.config.ts --mode development && testem ci -f testem-electron.cjs", - "test:ember": "vite build -c vite.renderer.config.ts --mode development && testem ci", - "test:magnifier": "vitest run -c electron-app/magnifier/vitest.config.ts", - "test:magnifier:watch": "vitest -c electron-app/magnifier/vitest.config.ts", - "test:rust": "cd electron-app/magnifier/rust-sampler && cargo test" + "start:electron": "cross-env ELECTRON_DISABLE_SANDBOX=1 electron-forge start -- --no-sandbox --disable-gpu-sandbox --ozone-platform=x11", + "test:electron": "cross-env SKIP_CODESIGN=true electron-forge package && vite build -c vite.renderer.config.ts --mode development && testem ci -f testem-electron.cjs", + "test:ember": "vite build -c vite.renderer.config.ts --mode development && testem ci" }, "dependencies": { "@ctrl/tinycolor": "^4.2.0", @@ -91,6 +85,7 @@ "ember-simple-auth": "^8.2.0", "ember-source": "^6.10.0", "ember-svg-jar": "^2.7.1", + "hue-hunter": "^0.4.0", "indexeddb-export-import": "^2.1.5", "menubar": "^9.5.2", "throttle-debounce": "^5.0.2", @@ -221,6 +216,7 @@ "esbuild", "fs-xattr", "fsevents", + "hue-hunter", "macos-alias" ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4ffb6a2..e07e2bd2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: ember-svg-jar: specifier: ^2.7.1 version: 2.7.1(@glint/template@1.5.2) + hue-hunter: + specifier: ^0.4.0 + version: 0.4.0(electron@40.0.0) indexeddb-export-import: specifier: ^2.1.5 version: 2.1.5 @@ -6841,6 +6844,12 @@ packages: https@1.0.0: resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==} + hue-hunter@0.4.0: + resolution: {integrity: sha512-R9UleHA38W79n2B2l6y+wLF6L5LTFNy4P3/3naoi8hp+9AsWDnM8RR4PO2nvvBtxW7Mo9Cd7aCzVGOlk1CzlXQ==} + engines: {node: '>=24.0.0'} + peerDependencies: + electron: '>=38.0.0' + human-signals@1.1.1: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} @@ -7704,6 +7713,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@2.2.0: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} @@ -14723,7 +14735,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 13.0.0 '@types/graceful-fs@4.1.9': dependencies: @@ -15421,7 +15433,7 @@ snapshots: convert-source-map: 1.9.0 debug: 2.6.9 json5: 0.5.1 - lodash: 4.17.21 + lodash: 4.17.23 minimatch: 3.1.2 path-is-absolute: 1.0.1 private: 0.1.8 @@ -15438,7 +15450,7 @@ snapshots: babel-types: 6.26.0 detect-indent: 4.0.0 jsesc: 1.3.0 - lodash: 4.17.21 + lodash: 4.17.23 source-map: 0.5.7 trim-right: 1.0.1 optional: true @@ -15918,7 +15930,7 @@ snapshots: babel-runtime: 6.26.0 core-js: 2.6.12 home-or-tmp: 2.0.0 - lodash: 4.17.21 + lodash: 4.17.23 mkdirp: 0.5.6 source-map-support: 0.4.18 transitivePeerDependencies: @@ -19972,6 +19984,13 @@ snapshots: https@1.0.0: {} + hue-hunter@0.4.0(electron@40.0.0): + dependencies: + color-name-list: 14.27.0 + electron: 40.0.0 + electron-is-dev: 3.0.1 + nearest-color: 0.4.4 + human-signals@1.1.1: {} human-signals@2.1.0: {} @@ -20864,6 +20883,9 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: + optional: true + log-symbols@2.2.0: dependencies: chalk: 2.4.2 diff --git a/types/global.d.ts b/types/global.d.ts index 7941cbfa3..6d047b044 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -1,7 +1,6 @@ import 'ember-cli-flash'; import '@glint/environment-ember-loose' import { ModifierLike } from '@glint/template'; -import type { MagnifierAPI } from '../electron-app/magnifier/types'; import OnClickOutsideModifier from 'ember-click-outside/modifiers/on-click-outside'; @@ -43,7 +42,6 @@ declare global { removeAllListeners: (channel: string) => void; }; }; - magnifierAPI: MagnifierAPI; } namespace NodeJS { diff --git a/vite.magnifier.config.ts b/vite.magnifier.config.ts deleted file mode 100644 index aec22795d..000000000 --- a/vite.magnifier.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -import tailwindcss from '@tailwindcss/vite'; -import { defineConfig } from 'vite'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -export default defineConfig({ - root: 'electron-app/magnifier', - plugins: [tailwindcss()], - clearScreen: false, - build: { - outDir: resolve(__dirname, '.vite/renderer/magnifier_window'), - emptyOutDir: true, - }, -});