From eef9a6231f9a3440fc0658f0197a652b2d05f04f Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 00:06:23 +0530 Subject: [PATCH 01/14] feat: tighten pencil engine, redesign arrow, refresh toolbar icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated improvements driven by user feedback that small handwriting strokes were blobby, the arrow looked unfinished, and the chrome felt dated. Stroke engine — fine handwriting now works: - perfect-freehand pencil thinning 0.08 → 0.04 and streamline 0.26 → 0.18 so sub-pixel widths stop being pinched out by outline smoothing. - Pencil width presets shifted to [0.5, 1, 2, 3, 6] and default pencil width dropped from 3 to 2 (DEFAULT_SETTINGS + persistence defaults). - Canvas DPR floored at 2× on both LiveLayer and CommittedLayer so strokes stay crisp on classroom IFPs / external 96-DPI monitors that report devicePixelRatio = 1. Arrow tool — original parametric geometry: - Head length scales with both arrow length and stroke width, capped at 45% of total length, floored at 12px. - Slender 0.72 aspect ratio + swept-back chevron notch read as a designed arrowhead instead of a flat-based triangle. - Shaft ends at the notch, eliminating the round-cap blob that small arrows used to show at the join. Toolbar UI — Phosphor-inspired refresh: - All toolbar icons redrawn in the Phosphor Icons regular-weight visual language: 1.4 stroke (was 1.8), simpler geometry, no decorative ferrules / ribs. Fresh hand-drawn SVGs, not verbatim copies of any third-party set. - Tool / action buttons tightened from 30×30 to 26×26. - Color swatches: 18px circles → 22px rounded squares with 2px border; hover scale 1.15 → 1.08 for a calmer feel. --- package-lock.json | 9 +- src/main/persistence.ts | 2 +- src/renderer/overlay/canvas/CommittedLayer.ts | 4 +- src/renderer/overlay/canvas/LiveLayer.ts | 12 +- src/renderer/overlay/canvas/drawItem.ts | 64 +++++-- src/renderer/toolbar/icons.tsx | 163 +++++++++++------- src/renderer/toolbar/styles.css | 32 ++-- src/shared/constants.ts | 4 +- 8 files changed, 191 insertions(+), 99 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2416f18..09e8166 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "pen-trader", - "version": "0.1.0", + "name": "@opensourcebharat/lekhini", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pen-trader", - "version": "0.1.0", + "name": "@opensourcebharat/lekhini", + "version": "1.0.0", + "license": "MIT", "dependencies": { "active-win": "^8.2.1", "electron-store": "^10.0.0", diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d9f69b3..bfa427f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -16,7 +16,7 @@ export const PERSISTED_DEFAULTS: PersistedState = { orientation: 'v', theme: 'dark', profile: 'general', - perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + perToolWidth: { pencil: 2, pen: 4, eraser: 20, highlighter: 18 }, color: GRAPHITE_COLOR, activeTool: 'pencil', }; diff --git a/src/renderer/overlay/canvas/CommittedLayer.ts b/src/renderer/overlay/canvas/CommittedLayer.ts index 24112bb..e1d0a56 100644 --- a/src/renderer/overlay/canvas/CommittedLayer.ts +++ b/src/renderer/overlay/canvas/CommittedLayer.ts @@ -13,13 +13,13 @@ export class CommittedLayer { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) throw new Error('CommittedLayer: 2D context unavailable'); this.ctx = ctx; - this.dpr = window.devicePixelRatio || 1; + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.resize(); } resize(): void { const { innerWidth: w, innerHeight: h } = window; - this.dpr = window.devicePixelRatio || 1; + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.canvas.width = Math.floor(w * this.dpr); this.canvas.height = Math.floor(h * this.dpr); this.canvas.style.width = `${w}px`; diff --git a/src/renderer/overlay/canvas/LiveLayer.ts b/src/renderer/overlay/canvas/LiveLayer.ts index 029f379..a692ae1 100644 --- a/src/renderer/overlay/canvas/LiveLayer.ts +++ b/src/renderer/overlay/canvas/LiveLayer.ts @@ -13,13 +13,21 @@ export class LiveLayer { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) throw new Error('LiveLayer: 2D context unavailable'); this.ctx = ctx; - this.dpr = window.devicePixelRatio || 1; + // Floor at 2× so strokes stay crisp on standard-DPI external monitors + // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling + // a true Retina or higher display. ~4× canvas memory vs DPR=1, which + // is fine for one full-screen overlay. + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.resize(); } resize(): void { const { innerWidth: w, innerHeight: h } = window; - this.dpr = window.devicePixelRatio || 1; + // Floor at 2× so strokes stay crisp on standard-DPI external monitors + // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling + // a true Retina or higher display. ~4× canvas memory vs DPR=1, which + // is fine for one full-screen overlay. + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.canvas.width = Math.floor(w * this.dpr); this.canvas.height = Math.floor(h * this.dpr); this.canvas.style.width = `${w}px`; diff --git a/src/renderer/overlay/canvas/drawItem.ts b/src/renderer/overlay/canvas/drawItem.ts index 30ca52d..c2ad9e8 100644 --- a/src/renderer/overlay/canvas/drawItem.ts +++ b/src/renderer/overlay/canvas/drawItem.ts @@ -130,14 +130,20 @@ function drawStroke( end: { taper: 0, cap: true }, }; } else if (isPencil) { + // Tuned for fine-handwriting at sub-pixel widths: lower thinning so + // a 0.5–1px pencil doesn't get pinched into invisibility by + // perfect-freehand's outline algorithm, and lower streamline so the + // line actually follows the writer's wrist instead of being eaten + // by post-hoc smoothing. Tapers shrink proportionally so very fine + // strokes still end cleanly. opts = { - thinning: 0.08, - smoothing: 0.32, - streamline: 0.26, + thinning: 0.04, + smoothing: 0.28, + streamline: 0.18, easing: (t: number) => t, simulatePressure: false, - start: { taper: Math.min(effectiveWidth * 0.6, 6), cap: true }, - end: { taper: Math.min(effectiveWidth * 0.9, 10), cap: true }, + start: { taper: Math.min(effectiveWidth * 0.5, 4), cap: true }, + end: { taper: Math.min(effectiveWidth * 0.7, 7), cap: true }, }; } else { // pen @@ -292,21 +298,59 @@ function drawEllipse( function drawArrow(ctx: CanvasRenderingContext2D, item: Extract): void { const dx = item.p2.x - item.p1.x; const dy = item.p2.y - item.p1.y; + const length = Math.hypot(dx, dy); + if (length < 1) return; const angle = Math.atan2(dy, dx); - const head = Math.max(8, item.width * 3); + + // Head geometry: parametric in both length and width, capped at 45% + // of total length so very short arrows don't become all head, and + // floored so very thin arrows still read as arrows. The 0.72 aspect + // ratio (width as fraction of length) gives a slender, "designed" + // silhouette rather than the chunky 90° triangle a fixed-angle head + // produces. Notch at 0.22 of head length pulls the back inward so + // the head reads as a swept chevron, not a flat-based pyramid. + const widthBoost = 1 + item.width / 30; + const headLen = Math.max(12, Math.min(length * 0.22 * widthBoost, length * 0.45)); + const headHalfW = headLen * 0.36; + const notchDepth = headLen * 0.22; + + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const perpX = -sinA; + const perpY = cosA; + + const tipX = item.p2.x; + const tipY = item.p2.y; + const backX = item.p2.x - headLen * cosA; + const backY = item.p2.y - headLen * sinA; + const notchX = backX + notchDepth * cosA; + const notchY = backY + notchDepth * sinA; + const wingLX = backX + headHalfW * perpX; + const wingLY = backY + headHalfW * perpY; + const wingRX = backX - headHalfW * perpX; + const wingRY = backY - headHalfW * perpY; + ctx.save(); ctx.strokeStyle = item.color; ctx.fillStyle = item.color; ctx.lineWidth = item.width; ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Shaft stops at the notch — if we drew through to p2 the head fill + // would overlap the shaft's round cap and give small arrows a visible + // blob at the join. Ending at the notch makes the silhouette one + // continuous shape. ctx.beginPath(); ctx.moveTo(item.p1.x, item.p1.y); - ctx.lineTo(item.p2.x, item.p2.y); + ctx.lineTo(notchX, notchY); ctx.stroke(); + ctx.beginPath(); - ctx.moveTo(item.p2.x, item.p2.y); - ctx.lineTo(item.p2.x - head * Math.cos(angle - Math.PI / 7), item.p2.y - head * Math.sin(angle - Math.PI / 7)); - ctx.lineTo(item.p2.x - head * Math.cos(angle + Math.PI / 7), item.p2.y - head * Math.sin(angle + Math.PI / 7)); + ctx.moveTo(tipX, tipY); + ctx.lineTo(wingLX, wingLY); + ctx.lineTo(notchX, notchY); + ctx.lineTo(wingRX, wingRY); ctx.closePath(); ctx.fill(); ctx.restore(); diff --git a/src/renderer/toolbar/icons.tsx b/src/renderer/toolbar/icons.tsx index dda1fce..f5e944b 100644 --- a/src/renderer/toolbar/icons.tsx +++ b/src/renderer/toolbar/icons.tsx @@ -1,109 +1,135 @@ import type { JSX } from 'solid-js'; +// Toolbar icon set, redrawn in the Phosphor Icons visual language +// (https://phosphoricons.com — MIT). Each glyph is a fresh +// implementation tailored to Lekhini's 22×22 toolbar slot, not a +// verbatim copy of any Phosphor SVG. Stroke = 1.4 to match Phosphor's +// "regular" weight at 24px equivalent. Pure currentColor; the toolbar +// theme decides the actual hue via CSS. + const SVG = (children: JSX.Element): JSX.Element => ( - + {children} ); export const Icons = { + // PencilSimple — tilted shaft with a flat tip. No ferrule clutter. pencil: () => SVG( <> - {/* Pencil body with hex-style ferrule lines and a tip */} - - + , ), + // Pen — angled barrel with a small nib square at the tip. pen: () => SVG( <> - {/* Fountain pen: tapered body + visible nib slit */} - - - - + + , ), + // Highlighter — square chisel head over a tapered shaft. highlighter: () => SVG( <> - - - + + + , ), + // Eraser — block-tip eraser with a base shadow line. eraser: () => SVG( <> - - + + + , ), + // Hand — four-finger open palm. hand: () => SVG( <> - - - - + + + + , ), - line: () => SVG(), + // Minus — single horizontal line. + line: () => SVG(), + // TrendUp — diagonal line with terminal dots. trendline: () => SVG( <> - - - + + + , ), + // Stacked horizontal lines — Fibonacci/retracement. fib: () => SVG( <> - - - - + + + + , ), - region: () => SVG(), + // Selection — rounded dashed square. + region: () => SVG(), + // Crop — four L-corners with a dashed inner outline. snip: () => SVG( <> - + , ), + // Circle — clean ellipse. ellipse: () => SVG(), + // Chalkboard — rounded frame with two stand legs. whiteboard: () => SVG( <> - - - + + + , ), + // ArrowRight — single shaft with a clean chevron tip. arrow: () => SVG( <> - + , ), + // TextT — capital T with serifs at top and base. text: () => SVG( <> - + , ), + // ArrowUUpLeft — undo arrow. undo: () => SVG( <> @@ -111,6 +137,7 @@ export const Icons = { , ), + // ArrowUUpRight — redo arrow. redo: () => SVG( <> @@ -118,36 +145,41 @@ export const Icons = { , ), + // Trash — clean lid + bin, no inner ribs. clear: () => SVG( <> - - - + + + , ), + // Camera — body + shutter + grip notch. camera: () => SVG( <> - + , ), pause: () => SVG( <> - - + + , ), - play: () => SVG(), + play: () => + SVG(), + // Rows / orientation — two stacked rounded bars. orient: () => SVG( <> - - + + , ), + // X — diagonals. close: () => SVG( <> @@ -156,44 +188,52 @@ export const Icons = { , ), minus: () => SVG(), + // Sun — circle + 8 short cardinal/diagonal rays (Phosphor regular style). sun: () => SVG( <> - - - - - - - - - + + + + + + + + + , ), moon: () => SVG(), + // Gear — simpler 6-tooth wheel + center hub. Phosphor's actual gear + // has 8 teeth at this size; 6 reads cleaner in the 22px slot. gear: () => SVG( <> - + + + + + + + + , ), - check: () => - SVG(), + check: () => SVG(), + // Lines — three stacked horizontal bars, varying weight. thickness: () => SVG( <> - {/* Three stacked lines of increasing weight — a universal */} - {/* "thickness" / "stroke weight" icon. */} - - - + + + , ), + // ArrowsInSimple — two opposing chevrons. collapse: () => SVG( <> - {/* Inward-pointing chevrons — "minimize / collapse". */} , @@ -219,4 +259,3 @@ export const Logo = (): JSX.Element => ( ); - diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 4be445b..87c07c2 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -410,7 +410,7 @@ html, body { .bar[data-orient='v'] .tools-zone, .bar[data-orient='v'] .actions-zone { display: grid; - grid-template-columns: repeat(2, 30px); + grid-template-columns: repeat(2, 26px); gap: 4px; background: var(--group-bg); border: 1px solid var(--group-border); @@ -449,9 +449,9 @@ html, body { background: transparent; color: var(--text); border: 1px solid transparent; - border-radius: 6px; - width: 30px; - height: 30px; + border-radius: 7px; + width: 26px; + height: 26px; display: inline-flex; align-items: center; justify-content: center; @@ -486,7 +486,7 @@ html, body { color: var(--gold); opacity: 1; } -.tool-btn svg, .action-btn svg { width: 19px; height: 19px; } +.tool-btn svg, .action-btn svg { width: 18px; height: 18px; } /* ─────────────────── PINNED COLOR / WIDTH ─────────────────── */ @@ -500,26 +500,26 @@ html, body { gap: 12px; } -.swatches { display: flex; gap: 5px; align-items: center; } +.swatches { display: flex; gap: 6px; align-items: center; } .swatch { - width: 18px; - height: 18px; - border-radius: 50%; - border: 1.5px solid var(--swatch-border); + width: 22px; + height: 22px; + border-radius: 5px; + border: 2px solid var(--swatch-border); cursor: pointer; - transition: transform 0.08s, border-color 0.12s, box-shadow 0.12s; + transition: transform 0.10s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.14s, box-shadow 0.14s; } -.swatch:hover { transform: scale(1.15); border-color: var(--swatch-border-hover); } +.swatch:hover { transform: scale(1.08); border-color: var(--swatch-border-hover); } .swatch.active { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(216, 181, 114, 0.30); } .hex-pick { position: relative; - width: 20px; - height: 20px; - border-radius: 50%; - border: 1.5px dashed var(--swatch-border); + width: 22px; + height: 22px; + border-radius: 5px; + border: 2px dashed var(--swatch-border); display: inline-flex; align-items: center; justify-content: center; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 049306e..1e615c8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -7,7 +7,7 @@ export const GRAPHITE_COLOR = '#3a3a3c'; export const DEFAULT_SETTINGS: ToolSettings = { color: GRAPHITE_COLOR, - width: 3, + width: 2, opacity: 1, }; @@ -81,7 +81,7 @@ export const THICKNESS_PRESETS: Record< 'pencil' | 'pen' | 'eraser' | 'highlighter', number[] > = { - pencil: [1, 2, 3, 5, 8], + pencil: [0.5, 1, 2, 3, 6], pen: [2, 4, 8, 14, 22], eraser: [10, 18, 28, 44, 64], highlighter: [12, 18, 26, 34, 44], From e46e6533217ed57e34f4ef1f4203fb9e3c212387 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 00:18:23 +0530 Subject: [PATCH 02/14] feat: permission-aware screenshot + remember save folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When macOS Screen Recording was previously denied, the screenshot button silently did nothing — desktopCapturer.getSources() returned an empty array, capture exited at the first null branch, the renderer's 8-second IPC handler timed out, and the user had no feedback. This change closes that loop end-to-end across all three desktop OSes. Permission flow: - Main process never preflight-bails. On macOS 'granted' or 'not-determined' the capture proceeds, letting the OS surface its native first-run prompt where applicable. On Wayland Linux the xdg-desktop-portal natively shows 'Allow once / Allow always / Deny' at call time — exactly the granular UX the OS gives us. - On macOS 'denied', a new PermissionModal opens in the toolbar with 'Open System Settings' (deep-links to Privacy & Security → Screen Recording) and 'Recheck' buttons. The modal auto-rechecks when the toolbar window regains focus, so granting in Settings + alt-tab back closes the modal and re-runs the pending capture automatically. - New permissions:needed / permissions:status IPC channels broadcast state to every renderer. Save destination: - New persisted fields saveDir + alwaysAskSavePath flow through the hub like other settings. - First save shows the OS save dialog (default ~/Pictures/Lekhini/). The chosen folder is remembered; subsequent saves auto-write to /lekhini-YYYY-MM-DD-HHMMSS.png with no dialog. - Settings panel gains a 'File save' section: an 'Always ask where to save' toggle and a path button that opens a folder picker. - fs.writeFileSync wrapped in try/catch; failures emit capture:error. Renderer feedback: - New Toast component (success / error / info) renders a stack bottom-right with a Reveal action that calls shell.showItemInFolder. - Success path: 'Saved to ~/Pictures/Lekhini/lekhini-…png [Reveal]'. - Error path: surfaces the underlying message; auto-closes at 6s. Honest limits: - macOS cannot programmatically re-prompt once denied — only the Settings-deep-link + focus-recheck pattern is available, which is what reputable Mac capture apps already do. - Windows + X11 have no permission gate to surface; capture just works there. The Wayland portal owns its own UI. --- src/main/capture.ts | 198 ++++++++++++++++++-- src/main/hub.ts | 21 +++ src/main/permissions.ts | 43 ++++- src/main/persistence.ts | 11 ++ src/main/preload.ts | 25 +++ src/renderer/penApi.d.ts | 18 +- src/renderer/toolbar/App.tsx | 100 ++++++++++ src/renderer/toolbar/PermissionModal.tsx | 111 +++++++++++ src/renderer/toolbar/Toast.tsx | 89 +++++++++ src/renderer/toolbar/styles.css | 225 +++++++++++++++++++++++ src/shared/types.ts | 36 +++- 11 files changed, 857 insertions(+), 20 deletions(-) create mode 100644 src/renderer/toolbar/PermissionModal.tsx create mode 100644 src/renderer/toolbar/Toast.tsx diff --git a/src/main/capture.ts b/src/main/capture.ts index 653e29a..bbf1d59 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -1,8 +1,20 @@ -import { clipboard, desktopCapturer, dialog, ipcMain, nativeImage, screen, BrowserWindow } from 'electron'; +import { + clipboard, + desktopCapturer, + dialog, + ipcMain, + nativeImage, + screen, + shell, + BrowserWindow, +} from 'electron'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { getOverlays } from './windows/overlay'; +import { notifyStatus, onFocusRecheck, screenStatus } from './permissions'; +import { persisted } from './persistence'; +import { patch as patchHub } from './hub'; interface Rect { x: number; @@ -37,7 +49,48 @@ export function getFocusedDisplayId(): number { return screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).id; } +// ─── Permission gating ────────────────────────────────────────────── +// +// macOS controls whether `desktopCapturer.getSources()` returns +// anything. We never preflight-bail: on 'granted' we proceed, on +// 'not-determined' we still call so the OS shows its native +// first-run prompt, and only on 'denied' do we surface our own +// modal. When the user grants the permission and refocuses Lekhini, +// onFocusRecheck retries the pending capture automatically. + +type PendingAction = 'capture' | 'clipboard'; +let pendingAction: PendingAction | null = null; + +function broadcast(channel: string, payload?: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(channel, payload); + } +} + +function needsScreenModal(): boolean { + if (process.platform !== 'darwin') return false; + const status = screenStatus(); + return status === 'denied' || status === 'restricted'; +} + +function gateScreenForCapture(action: PendingAction): boolean { + if (!needsScreenModal()) return true; + broadcast('permissions:needed', { reason: 'screen' }); + pendingAction = action; + onFocusRecheck((newStatus) => { + notifyStatus(); + if (newStatus !== 'granted') return; + const a = pendingAction; + pendingAction = null; + if (a === 'capture') void captureFocusedDisplay(); + else if (a === 'clipboard') void copyFocusedSnipToClipboard(); + }); + return false; +} + export async function copyFocusedSnipToClipboard(): Promise { + if (!gateScreenForCapture('clipboard')) return; + const displayId = getFocusedDisplayId(); const rect = snipSelections.get(displayId); if (!rect) return; @@ -53,7 +106,14 @@ export async function copyFocusedSnipToClipboard(): Promise { await waitMs(60); const pngBase64 = await captureCroppedComposite(overlay, display, rect); - if (!pngBase64) return; + if (!pngBase64) { + if (handleCaptureFailure()) return; + broadcast('capture:error', { + message: "Couldn't read the screen — try again.", + recoverable: true, + }); + return; + } const buf = Buffer.from(pngBase64, 'base64'); const img = nativeImage.createFromBuffer(buf); @@ -61,6 +121,8 @@ export async function copyFocusedSnipToClipboard(): Promise { } export async function captureFocusedDisplay(): Promise { + if (!gateScreenForCapture('capture')) return; + const cursorPoint = screen.getCursorScreenPoint(); const display = screen.getDisplayNearestPoint(cursorPoint); const overlay = getOverlays().get(display.id); @@ -69,7 +131,11 @@ export async function captureFocusedDisplay(): Promise { if (!overlay || overlay.isDestroyed()) { // No overlay: fall back to a raw full-display capture. const dataUrl = await fullDisplayDataUrl(display); - if (dataUrl) await persistDataUrl(dataUrl); + if (!dataUrl) { + handleCaptureFailure(); + return; + } + await persistDataUrl(dataUrl); return; } @@ -78,13 +144,20 @@ export async function captureFocusedDisplay(): Promise { setSnipSelection(display.id, null); await waitMs(60); const pngBase64 = await captureCroppedComposite(overlay, display, selection); - if (pngBase64) await persistDataUrl(`data:image/png;base64,${pngBase64}`); + if (!pngBase64) { + handleCaptureFailure(); + return; + } + await persistDataUrl(`data:image/png;base64,${pngBase64}`); return; } // No selection: full-display composite (existing behavior). const dataUrl = await fullDisplayDataUrl(display); - if (!dataUrl) return; + if (!dataUrl) { + handleCaptureFailure(); + return; + } await new Promise((resolve) => { const channel = 'capture:screenshot:result'; @@ -102,6 +175,26 @@ export async function captureFocusedDisplay(): Promise { }); } +// Called when desktopCapturer returns nothing. On macOS this almost +// always means permission was denied at the system prompt (which we +// can't intercept). Re-check status and surface the modal so the user +// gets feedback instead of silent failure. +function handleCaptureFailure(): boolean { + if (needsScreenModal()) { + broadcast('permissions:needed', { reason: 'screen' }); + pendingAction = 'capture'; + onFocusRecheck((newStatus) => { + notifyStatus(); + if (newStatus === 'granted' && pendingAction === 'capture') { + pendingAction = null; + void captureFocusedDisplay(); + } + }); + return true; + } + return false; +} + async function fullDisplayDataUrl(display: Electron.Display): Promise { const sources = await desktopCapturer.getSources({ types: ['screen'], @@ -147,22 +240,70 @@ function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +// Default filename: `lekhini-YYYY-MM-DD-HHMMSS.png`. Stable enough to +// sort chronologically, short enough to read at a glance. +function defaultFilename(): string { + const d = new Date(); + const pad = (n: number): string => String(n).padStart(2, '0'); + return ( + `lekhini-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.png` + ); +} + +function defaultSaveDir(): string { + return path.join(os.homedir(), 'Pictures', 'Lekhini'); +} + async function persistDataUrl(dataUrl: string): Promise { const base64 = dataUrl.replace(/^data:image\/png;base64,/, ''); const buf = Buffer.from(base64, 'base64'); - const day = new Date().toISOString().slice(0, 10); - const defaultDir = path.join(os.homedir(), 'Pictures', 'Lekhini', day); - fs.mkdirSync(defaultDir, { recursive: true }); - const defaultPath = path.join(defaultDir, `lekhini-${Date.now()}.png`); + const state = persisted(); + const shouldPrompt = state.alwaysAskSavePath || !state.saveDir; - const result = await dialog.showSaveDialog({ - defaultPath, - filters: [{ name: 'PNG', extensions: ['png'] }], - }); + let target: string; + if (shouldPrompt) { + const seedDir = state.saveDir ?? defaultSaveDir(); + try { + fs.mkdirSync(seedDir, { recursive: true }); + } catch { + // Non-fatal — showSaveDialog will still work even if mkdir failed. + } + const result = await dialog.showSaveDialog({ + title: 'Save annotated screenshot', + defaultPath: path.join(seedDir, defaultFilename()), + filters: [{ name: 'PNG', extensions: ['png'] }], + }); + if (result.canceled || !result.filePath) return; + target = result.filePath; + // Remember the chosen folder so the next save can skip the dialog. + // Going through the hub keeps every renderer's Settings panel in sync. + patchHub({ saveDir: path.dirname(target) }); + } else { + // saveDir is non-null here per the check above. + const dir = state.saveDir!; + try { + fs.mkdirSync(dir, { recursive: true }); + } catch (err) { + broadcast('capture:error', { + message: `Couldn't create save folder ${dir}: ${(err as Error).message}`, + recoverable: true, + }); + return; + } + target = path.join(dir, defaultFilename()); + } - if (result.canceled || !result.filePath) return; - fs.writeFileSync(result.filePath, buf); + try { + fs.writeFileSync(target, buf); + broadcast('capture:saved', { path: target }); + } catch (err) { + broadcast('capture:error', { + message: `Couldn't save to ${target}: ${(err as Error).message}`, + recoverable: true, + }); + } } export function registerCaptureIpc() { @@ -172,5 +313,32 @@ export function registerCaptureIpc() { ipcMain.handle('snip:clear', (_evt, payload: { displayId: number }) => { setSnipSelection(payload.displayId, null); }); + // Renderer-triggered folder picker, used by the "Change…" button in + // Settings → File save. Returns the chosen path so the renderer can + // patch the hub with it (which is what persists + broadcasts to + // every window). We don't save here ourselves — the renderer owns + // the round-trip to keep the hub the single source of truth. + ipcMain.handle('settings:save-dir:pick', async () => { + const state = persisted(); + const result = await dialog.showOpenDialog({ + title: 'Choose save folder for screenshots', + defaultPath: state.saveDir ?? defaultSaveDir(), + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || !result.filePaths.length) return null; + return result.filePaths[0]; + }); + // Reveal-in-Finder / file-manager link for the saved-toast. + ipcMain.handle('shell:open-path', async (_evt, p: string) => { + if (!p) return; + try { + // showItemInFolder reveals the file with it selected — better + // than openPath which just opens the parent folder. + shell.showItemInFolder(p); + } catch { + // Fall back to opening the containing folder. + void shell.openPath(path.dirname(p)); + } + }); void BrowserWindow; } diff --git a/src/main/hub.ts b/src/main/hub.ts index 44928d3..d28afb2 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -27,6 +27,8 @@ export interface HubState { settingsOpen: boolean; thicknessFlyoutOpen: boolean; perToolWidth: PerToolWidth; + saveDir: string | null; + alwaysAskSavePath: boolean; } const state: HubState = { @@ -42,6 +44,8 @@ const state: HubState = { settingsOpen: false, thicknessFlyoutOpen: false, perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + saveDir: null, + alwaysAskSavePath: false, }; const subscribers = new Set(); @@ -100,6 +104,10 @@ export function hydrateFromPersistence(): void { highlighter: typeof storedW.highlighter === 'number' ? storedW.highlighter : PERSISTED_DEFAULTS.perToolWidth.highlighter, }; state.activeTool = VALID_TOOLS.has(p.activeTool) ? p.activeTool : 'pencil'; + // Save destination: null until the user picks one. Schema-tolerant — + // older installs without this key fall through to the default. + state.saveDir = typeof p.saveDir === 'string' ? p.saveDir : null; + state.alwaysAskSavePath = typeof p.alwaysAskSavePath === 'boolean' ? p.alwaysAskSavePath : false; // If the active tool is pencil, the canonical color is graphite — // don't restore a stray non-graphite value from a previous session. const colorForTool = @@ -247,6 +255,19 @@ export function patch(update: HubStateUpdate) { changed.add('settingsOpen'); } } + if (update.saveDir !== undefined && update.saveDir !== state.saveDir) { + state.saveDir = update.saveDir; + changed.add('saveDir'); + save('saveDir', state.saveDir); + } + if ( + update.alwaysAskSavePath !== undefined && + update.alwaysAskSavePath !== state.alwaysAskSavePath + ) { + state.alwaysAskSavePath = update.alwaysAskSavePath; + changed.add('alwaysAskSavePath'); + save('alwaysAskSavePath', state.alwaysAskSavePath); + } broadcast(changed); } diff --git a/src/main/permissions.ts b/src/main/permissions.ts index 4600002..0299465 100644 --- a/src/main/permissions.ts +++ b/src/main/permissions.ts @@ -1,12 +1,16 @@ -import { ipcMain, shell, systemPreferences } from 'electron'; +import { app, BrowserWindow, ipcMain, shell, systemPreferences } from 'electron'; +import type { ScreenPermissionStatus } from '../shared/types'; export interface PermissionStatus { - screen: 'granted' | 'denied' | 'restricted' | 'not-determined' | 'unknown'; + screen: ScreenPermissionStatus; accessibility: boolean; } export function check(): PermissionStatus { if (process.platform !== 'darwin') { + // On Windows + X11 there is no permission gate. On Wayland the + // portal mediates at call time so we still report 'granted' here + // and let the portal prompt fire when desktopCapturer is invoked. return { screen: 'granted', accessibility: true }; } return { @@ -15,6 +19,10 @@ export function check(): PermissionStatus { }; } +export function screenStatus(): ScreenPermissionStatus { + return check().screen; +} + export function open(which: 'screen' | 'accessibility') { if (process.platform !== 'darwin') return; const urls = { @@ -25,6 +33,37 @@ export function open(which: 'screen' | 'accessibility') { shell.openExternal(urls[which]); } +// Broadcast a permission-status update to every renderer that's +// currently alive. Used by onFocusRecheck and by capture.ts when it +// discovers a denial through the capturer's empty-source return. +export function notifyStatus(): void { + const payload = { screen: screenStatus() }; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('permissions:status', payload); + } +} + +// macOS-only: register a one-shot listener that fires the next time +// any of our windows regains focus. The user clicked "Open System +// Settings" in the modal, granted the permission, then alt-tabbed +// back to Lekhini — that focus event is our signal to recheck. The +// listener is auto-cleared after firing so we don't leak handlers +// across modal open/close cycles. +let pendingRecheck: (() => void) | null = null; +export function onFocusRecheck(cb: (status: ScreenPermissionStatus) => void): void { + // Replace any prior pending callback (no point in stacking). + pendingRecheck = () => cb(screenStatus()); + const handler = (): void => { + if (!pendingRecheck) return; + const fn = pendingRecheck; + pendingRecheck = null; + // Defer one tick — macOS sometimes updates TCC state slightly + // after the focus event fires. + setTimeout(fn, 120); + }; + app.once('browser-window-focus', handler); +} + export function registerPermissionsIpc() { ipcMain.handle('permissions:check', () => check()); ipcMain.handle('permissions:open', (_evt, which: 'screen' | 'accessibility') => open(which)); diff --git a/src/main/persistence.ts b/src/main/persistence.ts index bfa427f..677e740 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -8,6 +8,15 @@ export interface PersistedState { perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number }; color: string; activeTool: ToolId; + // Save destination for screenshot / snip PNGs. `null` until the + // first save — the first save shows the OS dialog so the user + // explicitly picks a folder; that folder is then remembered and + // subsequent saves go straight to it with a timestamped filename. + saveDir: string | null; + // If true, every save shows the OS dialog regardless of saveDir. + // Off by default — the "remember + auto-save" UX is the recommended + // path. Lives in Settings → File save. + alwaysAskSavePath: boolean; } export const PERSISTED_DEFAULTS: PersistedState = { @@ -19,6 +28,8 @@ export const PERSISTED_DEFAULTS: PersistedState = { perToolWidth: { pencil: 2, pen: 4, eraser: 20, highlighter: 18 }, color: GRAPHITE_COLOR, activeTool: 'pencil', + saveDir: null, + alwaysAskSavePath: false, }; interface MinimalStore { diff --git a/src/main/preload.ts b/src/main/preload.ts index 95c4c2e..7729d37 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -63,6 +63,31 @@ const api = { check: () => ipcRenderer.invoke('permissions:check' satisfies IpcChannel), open: (which: 'screen' | 'accessibility') => ipcRenderer.invoke('permissions:open' satisfies IpcChannel, which), + onNeeded: (cb: (payload: { reason: 'screen' }) => void) => + bind('permissions:needed', cb as (v: unknown) => void), + onStatus: ( + cb: (payload: { + screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + }) => void, + ) => bind('permissions:status', cb as (v: unknown) => void), + }, + capture: { + onSaved: (cb: (payload: { path: string }) => void) => + bind('capture:saved', cb as (v: unknown) => void), + onError: (cb: (payload: { message: string; recoverable: boolean }) => void) => + bind('capture:error', cb as (v: unknown) => void), + }, + settings: { + // saveDir + alwaysAskSavePath are part of HubState — write them + // via `pen.hub.update({ saveDir, alwaysAskSavePath })`. This + // method only opens the OS folder-picker dialog and returns the + // chosen path so the renderer can patch the hub with it. + pickSaveDir: () => + ipcRenderer.invoke('settings:save-dir:pick' satisfies IpcChannel) as Promise, + }, + shell: { + openPath: (p: string) => + ipcRenderer.invoke('shell:open-path' satisfies IpcChannel, p), }, app: { info: () => diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts index 0c53868..42b8804 100644 --- a/src/renderer/penApi.d.ts +++ b/src/renderer/penApi.d.ts @@ -1,4 +1,4 @@ -import type { HubStateUpdate } from '../shared/types'; +import type { HubStateUpdate, ScreenPermissionStatus } from '../shared/types'; declare global { interface Window { @@ -50,8 +50,22 @@ declare global { setContentSize(payload: { axis: 'h' | 'v'; size: number }): Promise; }; permissions: { - check(): Promise<{ screen: string; accessibility: boolean }>; + check(): Promise<{ screen: ScreenPermissionStatus; accessibility: boolean }>; open(which: 'screen' | 'accessibility'): Promise; + onNeeded(cb: (payload: { reason: 'screen' }) => void): () => void; + onStatus(cb: (payload: { screen: ScreenPermissionStatus }) => void): () => void; + }; + capture: { + onSaved(cb: (payload: { path: string }) => void): () => void; + onError( + cb: (payload: { message: string; recoverable: boolean }) => void, + ): () => void; + }; + settings: { + pickSaveDir(): Promise; + }; + shell: { + openPath(p: string): Promise; }; app: { info(): Promise<{ name: string; version: string }>; diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 40fe4f3..255f140 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -4,12 +4,15 @@ import { PROFILES, PROFILE_ORDER } from '../../shared/profiles'; import type { Orientation, ProfileId, + ScreenPermissionStatus, Theme, ToolId, ToolSettings, Whiteboard, } from '../../shared/types'; import { Icons, Logo } from './icons'; +import { PermissionModal } from './PermissionModal'; +import { Toast, type ToastItem } from './Toast'; interface HubSnapshot { activeTool: ToolId; @@ -23,6 +26,8 @@ interface HubSnapshot { settingsOpen: boolean; thicknessFlyoutOpen: boolean; perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number }; + saveDir: string | null; + alwaysAskSavePath: boolean; } type FlyoutTool = 'pencil' | 'pen' | 'eraser' | 'highlighter'; @@ -60,6 +65,20 @@ const TOOL_BY_ID: Record = ALL_TOOLS.reduce( {} as Record, ); +// Display-friendly path: replace the home dir with `~` and ellipsize +// the middle if the result is still long. Pure cosmetic — the toast +// is narrow and a full POSIX path overflows. +function shortenPath(p: string, max = 56): string { + let s = p; + // Best-effort home detection — `process.env.HOME` is not available + // in the renderer; fall back to the common macOS / Linux prefix. + const home = /^\/Users\/[^/]+/.exec(s)?.[0] ?? /^\/home\/[^/]+/.exec(s)?.[0]; + if (home && s.startsWith(home)) s = '~' + s.slice(home.length); + if (s.length <= max) return s; + const tail = s.slice(-(max - 3)); + return '…' + tail; +} + export function ToolbarApp() { const [hub, setHub] = createSignal({ activeTool: 'pencil', @@ -73,7 +92,24 @@ export function ToolbarApp() { settingsOpen: false, thicknessFlyoutOpen: false, perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + saveDir: null, + alwaysAskSavePath: false, }); + const [permModalOpen, setPermModalOpen] = createSignal(false); + const [permStatus, setPermStatus] = createSignal(null); + const [toasts, setToasts] = createSignal([]); + let toastSeq = 0; + const pushToast = (t: Omit): void => { + const item: ToastItem = { ...t, id: ++toastSeq }; + setToasts((prev) => { + // Cap visible toasts at 3 — older ones fall off. + const next = [...prev, item]; + return next.length > 3 ? next.slice(next.length - 3) : next; + }); + }; + const dismissToast = (id: number): void => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; const [platform, setPlatform] = createSignal('darwin'); const [hint, setHint] = createSignal(''); const [settingsOnLeft, setSettingsOnLeft] = createSignal(false); @@ -101,6 +137,33 @@ export function ToolbarApp() { }); onCleanup(off); + // ── Permission + capture event wiring ──────────────────────── + // Main process emits 'permissions:needed' the moment a capture + // can't proceed; we mount the modal. 'permissions:status' lets + // main inform us of the current screen-recording state — used by + // the modal's Recheck button. + const offNeeded = window.pen.permissions.onNeeded(() => setPermModalOpen(true)); + const offStatus = window.pen.permissions.onStatus((p) => { + setPermStatus(p.screen); + if (p.screen === 'granted') setPermModalOpen(false); + }); + const offSaved = window.pen.capture.onSaved((p) => { + pushToast({ + kind: 'success', + message: `Saved to ${shortenPath(p.path)}`, + action: { label: 'Reveal', run: () => void window.pen.shell.openPath(p.path) }, + }); + }); + const offError = window.pen.capture.onError((p) => { + pushToast({ kind: 'error', message: p.message }); + }); + onCleanup(() => { + offNeeded(); + offStatus(); + offSaved(); + offError(); + }); + const el = scrollRef; if (!el) return; @@ -263,6 +326,12 @@ export function ToolbarApp() { void window.pen.hub.update({ theme: hub().theme === 'dark' ? 'light' : 'dark' }); }; const setProfile = (p: ProfileId) => void window.pen.hub.update({ profile: p }); + const toggleAlwaysAsk = () => + void window.pen.hub.update({ alwaysAskSavePath: !hub().alwaysAskSavePath }); + const pickSaveDir = async () => { + const dir = (await window.pen.settings.pickSaveDir()) as string | null; + if (dir) void window.pen.hub.update({ saveDir: dir }); + }; const toggleSettings = () => { const next = !hub().settingsOpen; if (next) refreshSide(); @@ -692,6 +761,31 @@ export function ToolbarApp() { +
+ +
+ Always ask where to save + +
+
+ Save folder + +
+
+
@@ -712,6 +806,12 @@ export function ToolbarApp() {
+ setPermModalOpen(false)} + /> +
); } diff --git a/src/renderer/toolbar/PermissionModal.tsx b/src/renderer/toolbar/PermissionModal.tsx new file mode 100644 index 0000000..d93a080 --- /dev/null +++ b/src/renderer/toolbar/PermissionModal.tsx @@ -0,0 +1,111 @@ +import { createSignal, onCleanup, onMount, Show } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { Icons } from './icons'; + +interface Props { + open: boolean; + // Renderer informs the modal when the OS reports a fresh status — + // used for the "still denied" hint after a manual Recheck. + lastStatus: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown' | null; + onClose: () => void; +} + +// PermissionModal — surfaced when desktopCapturer can't proceed (macOS +// Screen Recording denied, or Linux Wayland user picked Deny in the +// portal). The macOS path is the one that needs hand-holding because +// the OS will not re-prompt programmatically; the user must toggle +// Lekhini on in System Settings and return focus, at which point we +// auto-recheck and the modal closes itself. +export function PermissionModal(props: Props) { + const [busy, setBusy] = createSignal(false); + const [stillDenied, setStillDenied] = createSignal(false); + const isMac = (): boolean => navigator.userAgent.toLowerCase().includes('mac'); + + const openSettings = (): void => { + if (busy()) return; + setBusy(true); + void window.pen.permissions.open('screen'); + // Re-enable Recheck quickly so the user can confirm after granting. + setTimeout(() => setBusy(false), 600); + }; + + const recheck = async (): Promise => { + setBusy(true); + try { + const status = (await window.pen.permissions.check()) as { + screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + }; + if (status.screen === 'granted') { + props.onClose(); + } else { + setStillDenied(true); + } + } finally { + setBusy(false); + } + }; + + // Auto-recheck whenever this window regains focus — the user almost + // always reaches us via System Settings → toggle Lekhini → ⌘-tab + // back. The focus event is the cheap, reliable trigger. + let focusHandler: (() => void) | null = null; + onMount(() => { + focusHandler = () => { + if (!props.open) return; + void recheck(); + }; + window.addEventListener('focus', focusHandler); + }); + onCleanup(() => { + if (focusHandler) window.removeEventListener('focus', focusHandler); + }); + + return ( + + +
@@ -525,7 +590,14 @@ export function ToolbarApp() {
-
+
{ + const p = revealPath(); + if (p) void window.pen.shell.openPath(p); + }} + > {vertHintLine()}
@@ -814,13 +886,89 @@ export function ToolbarApp() {
+ + {/* ─── STATUS PANEL (permission / save error) ────────────── + Reuses the .settings-panel layout slot so it docks like + the Settings panel and grows the toolbar window the same + way. Settings has render priority — we only show this + when the settings panel is closed. */} + +
+
+ + {panelKind() === 'permission' ? 'Screen Recording' : "Couldn't save screenshot"} + + +
+ + +
+
+ {Icons.camera()} +
+ Lekhini needs Screen Recording permission to capture annotated + screenshots.{' '} + + macOS controls this — toggle Lekhini on under Privacy & + Security → Screen Recording, then return here. Lekhini + retries automatically when you come back. + + + You denied the system prompt last time. Try the screenshot + button again to be asked once more. + +
+
+ +
{panelHint()}
+
+
+ + + + +
+
+
+ + +
+
+ {Icons.clear()} +
{panelError() ?? 'Unknown error.'}
+
+
+ + +
+
+
+
+
- setPermModalOpen(false)} - /> - ); } diff --git a/src/renderer/toolbar/PermissionModal.tsx b/src/renderer/toolbar/PermissionModal.tsx deleted file mode 100644 index d93a080..0000000 --- a/src/renderer/toolbar/PermissionModal.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { createSignal, onCleanup, onMount, Show } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { Icons } from './icons'; - -interface Props { - open: boolean; - // Renderer informs the modal when the OS reports a fresh status — - // used for the "still denied" hint after a manual Recheck. - lastStatus: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown' | null; - onClose: () => void; -} - -// PermissionModal — surfaced when desktopCapturer can't proceed (macOS -// Screen Recording denied, or Linux Wayland user picked Deny in the -// portal). The macOS path is the one that needs hand-holding because -// the OS will not re-prompt programmatically; the user must toggle -// Lekhini on in System Settings and return focus, at which point we -// auto-recheck and the modal closes itself. -export function PermissionModal(props: Props) { - const [busy, setBusy] = createSignal(false); - const [stillDenied, setStillDenied] = createSignal(false); - const isMac = (): boolean => navigator.userAgent.toLowerCase().includes('mac'); - - const openSettings = (): void => { - if (busy()) return; - setBusy(true); - void window.pen.permissions.open('screen'); - // Re-enable Recheck quickly so the user can confirm after granting. - setTimeout(() => setBusy(false), 600); - }; - - const recheck = async (): Promise => { - setBusy(true); - try { - const status = (await window.pen.permissions.check()) as { - screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; - }; - if (status.screen === 'granted') { - props.onClose(); - } else { - setStillDenied(true); - } - } finally { - setBusy(false); - } - }; - - // Auto-recheck whenever this window regains focus — the user almost - // always reaches us via System Settings → toggle Lekhini → ⌘-tab - // back. The focus event is the cheap, reliable trigger. - let focusHandler: (() => void) | null = null; - onMount(() => { - focusHandler = () => { - if (!props.open) return; - void recheck(); - }; - window.addEventListener('focus', focusHandler); - }); - onCleanup(() => { - if (focusHandler) window.removeEventListener('focus', focusHandler); - }); - - return ( - - - diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index effef48..a236384 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -998,6 +998,18 @@ html, body { } .status-btn-primary:hover { filter: brightness(1.05); } +/* Relaunch — clearly destructive-ish, dimmed so it doesn't compete + with the primary action but visible enough to be the escape hatch + when Recheck keeps reporting denied. */ +.status-btn-relaunch { + background: transparent !important; + border-color: rgba(231, 76, 60, 0.4) !important; + color: #e74c3c !important; +} +.status-btn-relaunch:hover { + background: rgba(231, 76, 60, 0.12) !important; +} + /* Clickable success hint in the titlebar — when revealPath is set the message becomes a Reveal link. Gold accent + pointer cursor. */ .hint.is-reveal, diff --git a/src/shared/types.ts b/src/shared/types.ts index 16156d6..f25b6c6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -179,6 +179,8 @@ export type IpcChannel = | 'permissions:open' | 'permissions:needed' | 'permissions:status' + | 'permissions:deep-recheck' + | 'app:relaunch' | 'settings:save-dir:pick' | 'shell:open-path'; From 902d39fbd5853df28ba0ccad28a7cf0de4d7c56c Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:14:22 +0530 Subject: [PATCH 07/14] fix: when getSources() throws, promote Relaunch over Recheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported that even after granting Screen Recording in macOS System Settings, the panel kept showing 'Still off' on focus-return auto-recheck. The console showed '[pen] deepRecheck capture probe failed Failed to get sources.' — desktopCapturer.getSources() was throwing outright, not just returning an empty array. This is a Chromium-in-Electron quirk on macOS: when the process starts with Screen Recording denied, Chromium initialises its capture pipeline in 'can't capture' mode and there's no way to refresh it from inside the running process. Only a restart recovers. Dev-mode amplifies the issue because the running process bundle id is 'com.github.Electron', not the production 'com.opensourcebharat.lekhini', so the TCC entry the user toggles may not even map to the process that's checking. Changes: - deepRecheck() now returns { screen, probeError } — probeError is true when getSources() threw (vs returned no sources). That's the signal that the process is permanently stuck this session. - notifyStatus + onStatus carry probeError end-to-end so the renderer knows the recovery story. - Permission panel reshuffles when probeError = true: * 'Relaunch Lekhini' becomes the gold primary button. * 'Open System Settings' demotes to secondary. * Hint turns red-tinted and reads 'macOS can't refresh the permission for a running process — Click Relaunch'. - New 'packaged' field on app:info; renderer shows a small italic footnote in dev mode explaining the quirk is dev-specific. --- src/main/capture.ts | 12 +++--- src/main/permissions.ts | 51 +++++++++++++++------- src/main/preload.ts | 1 + src/main/windows/toolbar.ts | 4 ++ src/renderer/penApi.d.ts | 8 ++-- src/renderer/toolbar/App.tsx | 76 ++++++++++++++++++++++++++------- src/renderer/toolbar/styles.css | 18 ++++++++ 7 files changed, 130 insertions(+), 40 deletions(-) diff --git a/src/main/capture.ts b/src/main/capture.ts index d80cec9..1f9626f 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -77,11 +77,11 @@ function gateScreenForCapture(action: PendingAction): boolean { if (!needsScreenModal()) return true; broadcast('permissions:needed', { reason: 'screen' }); pendingAction = action; - onFocusRecheck((newStatus) => { + onFocusRecheck((result) => { // Pass the fresh status explicitly so renderers don't see the // possibly-stale getMediaAccessStatus cache. - notifyStatus(newStatus); - if (newStatus !== 'granted') return; + notifyStatus(result.screen, result.probeError); + if (result.screen !== 'granted') return; const a = pendingAction; pendingAction = null; if (a === 'capture') void captureFocusedDisplay(); @@ -185,9 +185,9 @@ function handleCaptureFailure(): boolean { if (needsScreenModal()) { broadcast('permissions:needed', { reason: 'screen' }); pendingAction = 'capture'; - onFocusRecheck((newStatus) => { - notifyStatus(newStatus); - if (newStatus === 'granted' && pendingAction === 'capture') { + onFocusRecheck((result) => { + notifyStatus(result.screen, result.probeError); + if (result.screen === 'granted' && pendingAction === 'capture') { pendingAction = null; void captureFocusedDisplay(); } diff --git a/src/main/permissions.ts b/src/main/permissions.ts index e91f0a1..c8ea32d 100644 --- a/src/main/permissions.ts +++ b/src/main/permissions.ts @@ -30,6 +30,17 @@ export function screenStatus(): ScreenPermissionStatus { return check().screen; } +export interface DeepRecheckResult { + screen: ScreenPermissionStatus; + // True when desktopCapturer.getSources() threw outright (not just + // returned an empty array). On macOS this signals that Chromium's + // in-process capture pipeline initialised while permission was + // denied and is now permanently stuck — only restarting the + // process will recover. The renderer uses this to make "Relaunch" + // the recommended next action instead of "Recheck". + probeError: boolean; +} + // Active probe of the actual capture API. macOS's TCC layer returns a // cached value from getMediaAccessStatus that does NOT refresh just // because the user toggled Screen Recording on in System Settings — @@ -41,23 +52,28 @@ export function screenStatus(): ScreenPermissionStatus { // The probe requests a 1×1 thumbnail so it returns fast and uses // almost no memory. If we get any sources back, permission is real // and the user can proceed. -export async function deepRecheck(): Promise { - if (process.platform !== 'darwin') return 'granted'; +export async function deepRecheck(): Promise { + if (process.platform !== 'darwin') return { screen: 'granted', probeError: false }; const cached = screenStatus(); - if (cached === 'granted') return 'granted'; + if (cached === 'granted') return { screen: 'granted', probeError: false }; try { const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { width: 1, height: 1 }, }); - if (sources.length > 0) return 'granted'; + if (sources.length > 0) return { screen: 'granted', probeError: false }; + return { screen: screenStatus(), probeError: false }; } catch (err) { console.warn('[pen] deepRecheck capture probe failed', err); + // The throw itself is the diagnostic signal — the process can't + // recover until restart. Re-read cache one more time in case it + // flipped during the probe call. + const after = screenStatus(); + return { + screen: after === 'granted' ? 'granted' : cached, + probeError: true, + }; } - // Probe failed too — re-read the cache one more time in case it - // flipped during the probe, otherwise return whatever we saw first. - const after = screenStatus(); - return after === 'granted' ? 'granted' : cached; } export function open(which: 'screen' | 'accessibility') { @@ -76,9 +92,14 @@ export function open(which: 'screen' | 'accessibility') { // // Pass `override` when you already know the fresh status — e.g. after // deepRecheck() — so renderers receive the truth instead of the -// possibly-stale cached value from getMediaAccessStatus. -export function notifyStatus(override?: ScreenPermissionStatus): void { - const payload = { screen: override ?? screenStatus() }; +// possibly-stale cached value from getMediaAccessStatus. `probeError` +// piggy-backs along so the renderer can promote the Relaunch button +// when getSources() outright threw (process-permanently-stuck case). +export function notifyStatus( + override?: ScreenPermissionStatus, + probeError = false, +): void { + const payload = { screen: override ?? screenStatus(), probeError }; for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send('permissions:status', payload); } @@ -95,9 +116,9 @@ export function notifyStatus(override?: ScreenPermissionStatus): void { // The previous behaviour (stacking) leaked listeners every time a // denied screenshot was attempted, eventually hitting Node's // MaxListeners warning at 11. -let pendingRecheck: ((status: ScreenPermissionStatus) => void) | null = null; +let pendingRecheck: ((result: DeepRecheckResult) => void) | null = null; let activeFocusHandler: (() => void) | null = null; -export function onFocusRecheck(cb: (status: ScreenPermissionStatus) => void): void { +export function onFocusRecheck(cb: (result: DeepRecheckResult) => void): void { pendingRecheck = cb; if (activeFocusHandler) return; // already armed; just updated cb activeFocusHandler = () => { @@ -119,9 +140,7 @@ export function onFocusRecheck(cb: (status: ScreenPermissionStatus) => void): vo export function registerPermissionsIpc() { ipcMain.handle('permissions:check', () => check()); ipcMain.handle('permissions:open', (_evt, which: 'screen' | 'accessibility') => open(which)); - ipcMain.handle('permissions:deep-recheck', async () => ({ - screen: await deepRecheck(), - })); + ipcMain.handle('permissions:deep-recheck', () => deepRecheck()); // Relaunch escape hatch — surfaced in the permission panel for the // rare macOS cases where even the deep probe can't see a freshly // granted permission until the process restarts. diff --git a/src/main/preload.ts b/src/main/preload.ts index 3518700..26ea2d9 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -69,6 +69,7 @@ const api = { onStatus: ( cb: (payload: { screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + probeError?: boolean; }) => void, ) => bind('permissions:status', cb as (v: unknown) => void), }, diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index ee1013e..5115866 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -170,6 +170,10 @@ export function registerToolbarIpc() { ipcMain.handle('app:info', () => ({ name: app.getName(), version: app.getVersion(), + // isPackaged is the canonical Electron check for production vs + // dev-server run. Surfaced so the renderer can flag dev-mode TCC + // quirks (which are normal in dev but absent in packaged builds). + packaged: app.isPackaged, })); // Renderer reports its desired content height (vertical) or width // (horizontal). Main resizes the window to fit so empty space below diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts index 508a1b2..0fffba7 100644 --- a/src/renderer/penApi.d.ts +++ b/src/renderer/penApi.d.ts @@ -51,10 +51,12 @@ declare global { }; permissions: { check(): Promise<{ screen: ScreenPermissionStatus; accessibility: boolean }>; - deepCheck(): Promise<{ screen: ScreenPermissionStatus }>; + deepCheck(): Promise<{ screen: ScreenPermissionStatus; probeError: boolean }>; open(which: 'screen' | 'accessibility'): Promise; onNeeded(cb: (payload: { reason: 'screen' }) => void): () => void; - onStatus(cb: (payload: { screen: ScreenPermissionStatus }) => void): () => void; + onStatus( + cb: (payload: { screen: ScreenPermissionStatus; probeError?: boolean }) => void, + ): () => void; }; capture: { onSaved(cb: (payload: { path: string }) => void): () => void; @@ -69,7 +71,7 @@ declare global { openPath(p: string): Promise; }; app: { - info(): Promise<{ name: string; version: string }>; + info(): Promise<{ name: string; version: string; packaged: boolean }>; relaunch(): Promise; }; env: { diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 0e6d82b..24f5045 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -68,6 +68,22 @@ const TOOL_BY_ID: Record = ALL_TOOLS.reduce( {} as Record, ); +// Permission-panel hint copy. When probeError=true, desktopCapturer +// outright threw on the recheck attempt — the process can't pick the +// new TCC state up without a relaunch. +function stuckHint(probeError: boolean): string { + if (probeError) { + return ( + "macOS can't refresh the permission for a running process — " + + 'Click Relaunch to restart Lekhini and pick up the change.' + ); + } + return ( + "Still off. Make sure Lekhini is toggled on under Privacy & Security " + + '→ Screen Recording, then click Recheck.' + ); +} + // Display-friendly path: replace the home dir with `~` and ellipsize // the middle if the result is still long. Pure cosmetic — the toast // is narrow and a full POSIX path overflows. @@ -107,6 +123,10 @@ export function ToolbarApp() { const [panelKind, setPanelKind] = createSignal(null); const [panelError, setPanelError] = createSignal(null); const [panelHint, setPanelHint] = createSignal(null); + // True after a recheck attempt where desktopCapturer.getSources() + // outright threw — process is stuck until relaunch. Drives the + // panel to promote the Relaunch button over Recheck. + const [permStuck, setPermStuck] = createSignal(false); // Set by capture:saved so the titlebar hint becomes a clickable // 'Reveal' that opens the file's folder. Cleared on next hover hint // or after revealMs. @@ -115,9 +135,14 @@ export function ToolbarApp() { const [platform, setPlatform] = createSignal('darwin'); const [hint, setHint] = createSignal(''); const [settingsOnLeft, setSettingsOnLeft] = createSignal(false); - const [appInfo, setAppInfo] = createSignal<{ name: string; version: string }>({ + const [appInfo, setAppInfo] = createSignal<{ + name: string; + version: string; + packaged: boolean; + }>({ name: 'Lekhini', version: '1.0.0', + packaged: true, }); let scrollRef: HTMLDivElement | undefined; let barMainRef: HTMLDivElement | undefined; @@ -148,6 +173,7 @@ export function ToolbarApp() { // toast inside a tiny toolbar window. const offNeeded = window.pen.permissions.onNeeded(() => { setPanelHint(null); + setPermStuck(false); setPanelKind('permission'); // Close settings if it was occupying the slot. if (hub().settingsOpen) void window.pen.hub.update({ settingsOpen: false }); @@ -156,11 +182,10 @@ export function ToolbarApp() { if (p.screen === 'granted') { setPanelKind((k) => (k === 'permission' ? null : k)); setPanelHint(null); + setPermStuck(false); } else if (panelKind() === 'permission') { - setPanelHint( - "Still off — macOS sometimes only sees the change after a restart. " + - "If you just toggled it on, click Relaunch.", - ); + setPermStuck(!!p.probeError); + setPanelHint(stuckHint(!!p.probeError)); } }); const offSaved = window.pen.capture.onSaved((p) => { @@ -407,17 +432,16 @@ export function ToolbarApp() { // permission on in System Settings. The deep probe actually hits // desktopCapturer and forces a TCC refresh. setPanelHint('Checking…'); - const status = (await window.pen.permissions.deepCheck()) as { + const result = (await window.pen.permissions.deepCheck()) as { screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + probeError: boolean; }; - if (status.screen === 'granted') { + if (result.screen === 'granted') { closePanel(); - } else { - setPanelHint( - "Still off — macOS sometimes only sees the change after a restart. " + - "If you just toggled it on, click Relaunch.", - ); + return; } + setPermStuck(result.probeError); + setPanelHint(stuckHint(result.probeError)); }; const openScreenPrefs = () => void window.pen.permissions.open('screen'); const relaunchApp = () => void window.pen.app.relaunch(); @@ -963,10 +987,12 @@ export function ToolbarApp() { -
{panelHint()}
+
+ {panelHint()} +
- + + - + +
+ +
+ Dev mode — TCC quirks are normal here. The packaged + Lekhini build doesn't have this caching issue. +
+
diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index a236384..a7123b8 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -983,6 +983,24 @@ html, body { border-radius: 6px; } +/* When the recheck probe outright threw — process is stuck until + relaunch. Red-tinted hint draws attention to the Relaunch button. */ +.status-hint.is-stuck { + color: #e74c3c; + background: rgba(231, 76, 60, 0.10); + border-color: rgba(231, 76, 60, 0.30); +} + +/* Dev-mode footnote — small, italic, neutral. Disappears in packaged + builds where it's never relevant. */ +.status-footnote { + font-size: 10px; + line-height: 1.35; + color: var(--hint); + font-style: italic; + margin-top: 2px; +} + .status-actions { display: flex; flex-wrap: wrap; From 38d4180ee633eef3783b3e71881e6bca345b72e4 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:30:22 +0530 Subject: [PATCH 08/14] fix: snip live border + action menu; exclude toolbar from screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three user-reported issues addressed in one pass since they're all in the screenshot / snip surface: Toolbar in saved screenshots: - setContentProtection(true) on the toolbar window. macOS uses NSWindowSharingNone and Windows uses WDA_EXCLUDEFROMCAPTURE, so desktopCapturer (ours or any third-party recorder) skips it. The PNG now contains the underlying app + the user's annotations and nothing of Lekhini's chrome. Snip drag border invisible: - The snip tool was passing a generic 'region' item to setDraft, and drawRegion painted a 1px white dashed line — invisible against most desktops. Added an opt-in 'marchingAnts' flag on RegionShape and a two-pass black-underneath / white-on-top dashed render path keyed on the flag, mirroring the committed snip-selection style. The user-facing rectangle tool still gets its own coloured outline. No action UX after snip completes: - New SnipActions component renders a floating menu pinned to the bottom-right of the completed selection (auto-flips above the rect if it would clip the bottom edge of the display). [ Copy ] [ Save ] [ ✕ ] - Copy → existing pen.snip.copy() (clipboard), then clears the selection. User pastes wherever. - Save → existing pen.relay.screenshot(), which already respects the active selection and goes through the remember-folder save flow. - ✕ → just clears. - Menu is only mounted while drawMode is on AND the snip tool is the active tool — otherwise the overlay window is click-through and the menu would render but not respond. Hiding it keeps the screen clean. --- src/main/windows/toolbar.ts | 7 ++ src/renderer/overlay/App.tsx | 93 ++++++++++++++++++++++++- src/renderer/overlay/canvas/drawItem.ts | 34 ++++++--- src/renderer/overlay/index.html | 50 +++++++++++++ src/renderer/overlay/tools/snip.ts | 11 +-- src/shared/types.ts | 4 ++ 6 files changed, 185 insertions(+), 14 deletions(-) diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index 5115866..95ea62e 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -65,6 +65,13 @@ export function createToolbar(orientation: Orientation = 'h'): BrowserWindow { toolbar.setAlwaysOnTop(true, 'screen-saver', 2); toolbar.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // Hide the toolbar from screen capture so the screenshot the user + // takes via Lekhini contains the underlying app + their annotations + // but NOT our toolbar chrome. macOS uses NSWindowSharingNone; + // Windows uses SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE); + // Linux is no-op. Also keeps the toolbar out of any other capture + // tool the user runs (Loom, QuickTime, Zoom share, etc.). + toolbar.setContentProtection(true); if (VITE_DEV_SERVER_URL) { toolbar.loadURL(`${VITE_DEV_SERVER_URL}src/renderer/toolbar/index.html`); diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index e25782f..4ebdeb4 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -3,7 +3,7 @@ import { CommittedLayer } from './canvas/CommittedLayer'; import { LiveLayer } from './canvas/LiveLayer'; import { attachPointerPipeline } from './canvas/pointerPipeline'; import { cursorFor } from './cursors'; -import { store } from './store'; +import { store, type SnipRect } from './store'; import { buildRegistry } from './tools/registry'; import type { Item, Theme, ToolSettings, Whiteboard } from '../../shared/types'; import type { Tool, ToolContext } from './tools/types'; @@ -15,6 +15,10 @@ export function OverlayApp() { const [drawMode, setDrawMode] = createSignal(store.getState().drawMode); const [activeTool, setActiveToolSignal] = createSignal(store.getState().activeTool); const [whiteboard, setWhiteboard] = createSignal('off'); + // Reactive mirror of the store's snipRect so the SnipActions menu + // can re-render on Solid's signal cycle. Synced inside the store + // subscriber below. + const [snipRectSig, setSnipRectSig] = createSignal(null); let currentTheme: Theme = 'dark'; const applyCursor = () => { @@ -134,6 +138,7 @@ export function OverlayApp() { ) { committed.render(state.items, state.selectedId, state.snipRect); } + if (state.snipRect !== prev.snipRect) setSnipRectSig(state.snipRect); }); const onResize = () => { @@ -250,6 +255,92 @@ export function OverlayApp() { /> )} + {/* Menu is only operable while the snip tool is the active + drawing tool, because the overlay window is click-through + otherwise (setIgnoreMouseEvents). Hiding it then prevents a + visible-but-dead menu floating on screen. The underlying + selection stays in main's snipSelections map either way. + (Order matters: snipRectSig() goes last so the && chain + resolves to the SnipRect itself for Show's accessor.) */} + + {(rect) => } + + + ); +} + +// Floating Copy / Save / Cancel menu for the active snip selection. +// Anchored at the bottom-right corner of the rect with a small offset. +// Falls back to inside-rect-bottom-right if the rect is too close to +// the screen edge to fit the menu below it. +function SnipActions(props: { rect: SnipRect }) { + const MENU_W = 168; + const MENU_H = 32; + const GAP = 8; + const clearSnip = (): void => { + const displayId = window.pen.env.displayId(); + void window.pen.snip.clear({ displayId }); + }; + const onCopy = async (): Promise => { + await window.pen.snip.copy(); + clearSnip(); + }; + const onSave = (): void => { + // The main process's captureFocusedDisplay picks up the existing + // selection and writes a cropped PNG (going through the save + // dialog or the remembered folder). + void window.pen.relay.screenshot(); + // Don't clear the selection here — capture.ts clears the visual + // selection itself just before grabbing the pixels so it isn't + // baked into the PNG, then we let the user know via the titlebar + // hint in the toolbar window. + }; + const onCancel = (): void => clearSnip(); + + const positioned = (): { left: string; top: string } => { + const r = props.rect; + const winW = window.innerWidth; + const winH = window.innerHeight; + // Default: below the rect, right-aligned to its right edge. + let left = r.x + r.w - MENU_W; + let top = r.y + r.h + GAP; + // If it would overflow the bottom of the screen, place ABOVE the rect. + if (top + MENU_H > winH - 4) top = r.y - MENU_H - GAP; + // If still off-screen (very tall rect near top), tuck inside the rect. + if (top < 4) top = Math.min(r.y + r.h - MENU_H - GAP, winH - MENU_H - 4); + // Horizontal clamping: never let the menu fall off either edge. + left = Math.max(4, Math.min(left, winW - MENU_W - 4)); + return { left: `${left}px`, top: `${top}px` }; + }; + + return ( +
e.stopPropagation()} + > + + +
); } diff --git a/src/renderer/overlay/canvas/drawItem.ts b/src/renderer/overlay/canvas/drawItem.ts index c2ad9e8..a589ed4 100644 --- a/src/renderer/overlay/canvas/drawItem.ts +++ b/src/renderer/overlay/canvas/drawItem.ts @@ -262,14 +262,32 @@ function drawRegion( const w = Math.abs(item.p2.x - item.p1.x); const h = Math.abs(item.p2.y - item.p1.y); ctx.save(); - ctx.globalAlpha = item.opacity * 0.18; - ctx.fillStyle = item.color; - ctx.fillRect(x, y, w, h); - ctx.globalAlpha = item.opacity; - ctx.strokeStyle = item.color; - ctx.setLineDash([4, 4]); - ctx.lineWidth = 1; - ctx.strokeRect(x, y, w, h); + if (item.marchingAnts) { + // Snip preview: two-pass marching ants — black underneath, white on + // top with a dash-offset so the alternation is visible on light + // AND dark surfaces. Half-pixel offset for crisp 1px lines. A faint + // dim wash inside indicates the captured region. + ctx.globalAlpha = 0.18; + ctx.fillStyle = '#000000'; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.strokeRect(x + 0.5, y + 0.5, w, h); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)'; + ctx.lineDashOffset = 5; + ctx.strokeRect(x + 0.5, y + 0.5, w, h); + } else { + ctx.globalAlpha = item.opacity * 0.18; + ctx.fillStyle = item.color; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = item.opacity; + ctx.strokeStyle = item.color; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + } ctx.restore(); } diff --git a/src/renderer/overlay/index.html b/src/renderer/overlay/index.html index 8a3c9ef..be18975 100644 --- a/src/renderer/overlay/index.html +++ b/src/renderer/overlay/index.html @@ -58,6 +58,56 @@ min-width: 160px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); } + /* Snip action menu — Copy / Save / Cancel pinned to the + bottom-right of a completed snip selection. Sits ABOVE the + capture-surface in DOM order so clicks land on buttons rather + than the underlying drawing surface; we also stopPropagation + on pointerdown defensively. */ + .snip-actions { + position: absolute; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + background: rgba(20, 20, 22, 0.94); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + font-size: 12px; + line-height: 1; + z-index: 50; + animation: snipActionsIn 0.14s cubic-bezier(0.2, 0.7, 0.2, 1); + } + @keyframes snipActionsIn { + from { opacity: 0; transform: translateY(-2px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .snip-action { + appearance: none; + background: transparent; + color: #e4e4e6; + border: 1px solid transparent; + border-radius: 5px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s; + } + .snip-action:hover { background: rgba(255, 255, 255, 0.08); } + .snip-action:active { background: rgba(255, 255, 255, 0.14); } + .snip-action-primary { + background: linear-gradient(180deg, #d8b572 0%, #b89150 100%); + color: #1c1c1e; + font-weight: 600; + } + .snip-action-primary:hover { filter: brightness(1.05); } + .snip-action-quiet { + color: rgba(228, 228, 230, 0.65); + padding: 6px 8px; + font-size: 13px; + } + .snip-action-quiet:hover { color: #e4e4e6; } diff --git a/src/renderer/overlay/tools/snip.ts b/src/renderer/overlay/tools/snip.ts index b382c1a..043df29 100644 --- a/src/renderer/overlay/tools/snip.ts +++ b/src/renderer/overlay/tools/snip.ts @@ -2,9 +2,6 @@ import type { RegionShape } from '../../../shared/types'; import type { Tool } from './types'; import { nextId } from './types'; -const PREVIEW_COLOR = '#ffffff'; -const PREVIEW_OPACITY = 0.9; - export const snip: Tool = (() => { let draft: RegionShape | null = null; let anchor: { x: number; y: number } | null = null; @@ -21,8 +18,12 @@ export const snip: Tool = (() => { id: nextId('snip-preview'), p1: anchor, p2: anchor, - color: PREVIEW_COLOR, - opacity: PREVIEW_OPACITY, + // Color/opacity are ignored when marchingAnts is set — the + // renderer paints its own high-contrast B/W pattern. Kept here + // for type-shape compatibility with RegionShape. + color: '#ffffff', + opacity: 1, + marchingAnts: true, }; ctx.setDraft(draft); }, diff --git a/src/shared/types.ts b/src/shared/types.ts index f25b6c6..e076d85 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -64,6 +64,10 @@ export interface RegionShape { p2: { x: number; y: number }; color: string; opacity: number; + // When set, render with the high-contrast B/W marching-ants pattern + // used for selection rectangles (snip preview). Without it the + // 1px-dashed white stroke is invisible against most desktops. + marchingAnts?: boolean; } export interface EllipseShape { From 71bfe9570a784bd7de14f19fa458efe3d494546f Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:39:37 +0530 Subject: [PATCH 09/14] fix: snip Copy / Save now exits drawMode so user can paste / move on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a snip action completes the user wants the overlay to step out of the way — clicking Copy is almost always followed by ⌘V into another app, and Save just means "I'm done with that selection." Staying in drawMode meant the overlay kept intercepting the next click and the user had to manually toggle pen mode off first. After Copy or Save we now patch hub.drawMode=false. The snip tool itself stays selected, so re-enabling drawMode (⌘⇧D or the status dot) jumps straight into another selection without a tool change. Cancel still just clears the rect without changing drawMode — the user explicitly said 'never mind' but probably wants to keep drawing. --- src/renderer/overlay/App.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index 4ebdeb4..1c8ffe8 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -281,19 +281,28 @@ function SnipActions(props: { rect: SnipRect }) { const displayId = window.pen.env.displayId(); void window.pen.snip.clear({ displayId }); }; + // After the user picks Copy or Save the snip is done — drop the + // overlay out of drawMode so it becomes click-through immediately, + // letting the user paste into another app or click around without + // the snip tool intercepting the next click. The snip tool stays + // selected, so re-enabling drawMode (⌘⇧D or status dot) jumps + // straight back into another selection. + const exitToIdle = (): void => { + void window.pen.hub.update({ drawMode: false }); + }; const onCopy = async (): Promise => { await window.pen.snip.copy(); clearSnip(); + exitToIdle(); }; const onSave = (): void => { // The main process's captureFocusedDisplay picks up the existing // selection and writes a cropped PNG (going through the save - // dialog or the remembered folder). + // dialog or the remembered folder). capture.ts also clears the + // visual selection itself just before grabbing the pixels so it + // isn't baked into the PNG. void window.pen.relay.screenshot(); - // Don't clear the selection here — capture.ts clears the visual - // selection itself just before grabbing the pixels so it isn't - // baked into the PNG, then we let the user know via the titlebar - // hint in the toolbar window. + exitToIdle(); }; const onCancel = (): void => clearSnip(); From aeea643d17c0510bb5f15c539322ac22e50e5a3d Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:50:08 +0530 Subject: [PATCH 10/14] perf: binary IPC + createImageBitmap + toBlob for snip + screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for snip Copy/Save to feel quick. Roughly halves the end-to-end latency from selection-release to clipboard-ready by cutting the base64 round-trips and the heavier image-decode path. End-to-end IPC is now Uint8Array all the way: - main: desktopCapturer.thumbnail.toPNG() returns a Buffer; sent directly to the overlay via overlay:snip / overlay:screenshot as { png: Buffer }. No more toDataURL → base64 string → 33% larger payload → renderer base64 decode → HTMLImageElement.src round-trip. - renderer: createImageBitmap(blob) decodes off the main thread and is materially faster than 'new Image; img.src = dataURL' for big full-screen PNGs. Composite uses the bitmap, then canvas.toBlob (async) → arrayBuffer for the result PNG. - main: receives Uint8Array from capture:snip:result / capture:screenshot:result, Buffer.from(uint8) — skips the base64 decode entirely. Clipboard write and fs.writeFile both work straight from the Buffer. Snip Copy UX: - Button shows 'Copying…' and disables itself + the others while the await is in flight, so the user can't double-click and knows something is happening. Save remains fire-and-forget (it goes through the save dialog or remembered folder and confirms via the toolbar's gold reveal hint). PNG format unchanged — still lossless, still universally clipboard-compatible. The savings come from skipping the base64 detour, not from quality loss. --- src/main/capture.ts | 60 ++++++++-------- src/main/preload.ts | 12 ++-- src/renderer/overlay/App.tsx | 117 ++++++++++++++++++++++---------- src/renderer/overlay/index.html | 3 + src/renderer/penApi.d.ts | 8 +-- 5 files changed, 124 insertions(+), 76 deletions(-) diff --git a/src/main/capture.ts b/src/main/capture.ts index 1f9626f..0ef37f8 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -107,8 +107,8 @@ export async function copyFocusedSnipToClipboard(): Promise { setSnipSelection(displayId, null); await waitMs(60); - const pngBase64 = await captureCroppedComposite(overlay, display, rect); - if (!pngBase64) { + const png = await captureCroppedComposite(overlay, display, rect); + if (!png) { if (handleCaptureFailure()) return; broadcast('capture:error', { message: "Couldn't read the screen — try again.", @@ -117,8 +117,7 @@ export async function copyFocusedSnipToClipboard(): Promise { return; } - const buf = Buffer.from(pngBase64, 'base64'); - const img = nativeImage.createFromBuffer(buf); + const img = nativeImage.createFromBuffer(png); clipboard.writeImage(img); } @@ -131,13 +130,13 @@ export async function captureFocusedDisplay(): Promise { const selection = snipSelections.get(display.id) ?? null; if (!overlay || overlay.isDestroyed()) { - // No overlay: fall back to a raw full-display capture. - const dataUrl = await fullDisplayDataUrl(display); - if (!dataUrl) { + // No overlay: fall back to a raw full-display capture (uncomposited). + const raw = await fullDisplayPng(display); + if (!raw) { handleCaptureFailure(); return; } - await persistDataUrl(dataUrl); + await persistPng(raw); return; } @@ -145,31 +144,35 @@ export async function captureFocusedDisplay(): Promise { // Clear the dashed selection visually before the screen grab. setSnipSelection(display.id, null); await waitMs(60); - const pngBase64 = await captureCroppedComposite(overlay, display, selection); - if (!pngBase64) { + const png = await captureCroppedComposite(overlay, display, selection); + if (!png) { handleCaptureFailure(); return; } - await persistDataUrl(`data:image/png;base64,${pngBase64}`); + await persistPng(png); return; } // No selection: full-display composite (existing behavior). - const dataUrl = await fullDisplayDataUrl(display); - if (!dataUrl) { + const screenPng = await fullDisplayPng(display); + if (!screenPng) { handleCaptureFailure(); return; } await new Promise((resolve) => { const channel = 'capture:screenshot:result'; - const handler = async (_evt: Electron.IpcMainInvokeEvent, pngBase64: string) => { + const handler = async (_evt: Electron.IpcMainInvokeEvent, png: Uint8Array) => { ipcMain.removeHandler(channel); - await persistDataUrl(`data:image/png;base64,${pngBase64}`); + await persistPng(Buffer.from(png)); resolve(); }; ipcMain.handle(channel, handler); - overlay.webContents.send('overlay:screenshot', { dataUrl }); + // Send the PNG as a Uint8Array — structured-clones across IPC as + // raw bytes (no base64 round-trip), about 33% less data than the + // old dataURL string and noticeably faster decode in the renderer + // via createImageBitmap. + overlay.webContents.send('overlay:screenshot', { png: screenPng }); setTimeout(() => { ipcMain.removeHandler(channel); resolve(); @@ -197,7 +200,7 @@ function handleCaptureFailure(): boolean { return false; } -async function fullDisplayDataUrl(display: Electron.Display): Promise { +async function fullDisplayPng(display: Electron.Display): Promise { const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { @@ -208,26 +211,28 @@ async function fullDisplayDataUrl(display: Electron.Display): Promise Number(s.display_id) === display.id) ?? sources[0]; if (!matching) return null; - return matching.thumbnail.toDataURL(); + // toPNG() goes straight to a Buffer — no base64 dataURL middleman. + // Buffer is structured-clonable over IPC as raw bytes. + return matching.thumbnail.toPNG(); } async function captureCroppedComposite( overlay: BrowserWindow, display: Electron.Display, rect: Rect, -): Promise { - const screenDataUrl = await fullDisplayDataUrl(display); - if (!screenDataUrl) return null; +): Promise { + const screenPng = await fullDisplayPng(display); + if (!screenPng) return null; - return new Promise((resolve) => { + return new Promise((resolve) => { const channel = 'capture:snip:result'; - const handler = (_evt: Electron.IpcMainInvokeEvent, pngBase64: string) => { + const handler = (_evt: Electron.IpcMainInvokeEvent, png: Uint8Array) => { ipcMain.removeHandler(channel); - resolve(pngBase64 || null); + resolve(png && png.byteLength > 0 ? Buffer.from(png) : null); }; ipcMain.handle(channel, handler); overlay.webContents.send('overlay:snip', { - dataUrl: screenDataUrl, + png: screenPng, rect, scaleFactor: display.scaleFactor, }); @@ -257,10 +262,7 @@ function defaultSaveDir(): string { return path.join(os.homedir(), 'Pictures', 'Lekhini'); } -async function persistDataUrl(dataUrl: string): Promise { - const base64 = dataUrl.replace(/^data:image\/png;base64,/, ''); - const buf = Buffer.from(base64, 'base64'); - +async function persistPng(buf: Buffer): Promise { const state = persisted(); const shouldPrompt = state.alwaysAskSavePath || !state.saveDir; diff --git a/src/main/preload.ts b/src/main/preload.ts index 26ea2d9..a1f1ef4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -16,11 +16,11 @@ const api = { onUndo: (cb: () => void) => bind('overlay:undo', cb), onRedo: (cb: () => void) => bind('overlay:redo', cb), onClear: (cb: () => void) => bind('overlay:clear', cb), - onScreenshot: (cb: (payload: { dataUrl: string }) => void) => + onScreenshot: (cb: (payload: { png: Uint8Array }) => void) => bind('overlay:screenshot', cb as (v: unknown) => void), onSnip: ( cb: (payload: { - dataUrl: string; + png: Uint8Array; rect: { x: number; y: number; w: number; h: number }; scaleFactor: number; }) => void, @@ -30,10 +30,10 @@ const api = { ) => bind('overlay:snip-selection', cb as (v: unknown) => void), requestFocus: () => ipcRenderer.invoke('overlay:request-focus' satisfies IpcChannel), releaseFocus: () => ipcRenderer.invoke('overlay:release-focus' satisfies IpcChannel), - sendScreenshotResult: (pngBase64: string) => - ipcRenderer.invoke('capture:screenshot:result' satisfies IpcChannel, pngBase64), - sendSnipResult: (pngBase64: string) => - ipcRenderer.invoke('capture:snip:result' satisfies IpcChannel, pngBase64), + sendScreenshotResult: (png: Uint8Array) => + ipcRenderer.invoke('capture:screenshot:result' satisfies IpcChannel, png), + sendSnipResult: (png: Uint8Array) => + ipcRenderer.invoke('capture:snip:result' satisfies IpcChannel, png), }, snip: { set: (payload: { diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index 1c8ffe8..9804517 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -155,13 +155,13 @@ export function OverlayApp() { const unUndo = window.pen.overlay.onUndo(() => store.getState().undo()); const unRedo = window.pen.overlay.onRedo(() => store.getState().redo()); const unClear = window.pen.overlay.onClear(() => store.getState().clear()); - const unShot = window.pen.overlay.onScreenshot(async ({ dataUrl }) => { - const png = await composite(dataUrl, committed.getCanvas()); - await window.pen.overlay.sendScreenshotResult(png); + const unShot = window.pen.overlay.onScreenshot(async ({ png }) => { + const out = await composite(png, committed.getCanvas()); + await window.pen.overlay.sendScreenshotResult(out); }); - const unSnip = window.pen.overlay.onSnip(async ({ dataUrl, rect, scaleFactor }) => { - const png = await compositeAndCrop(dataUrl, committed.getCanvas(), rect, scaleFactor); - await window.pen.overlay.sendSnipResult(png); + const unSnip = window.pen.overlay.onSnip(async ({ png, rect, scaleFactor }) => { + const out = await compositeAndCrop(png, committed.getCanvas(), rect, scaleFactor); + await window.pen.overlay.sendSnipResult(out); }); const unSnipSel = window.pen.overlay.onSnipSelection((rect) => { store.getState().setSnipRect(rect); @@ -277,6 +277,12 @@ function SnipActions(props: { rect: SnipRect }) { const MENU_W = 168; const MENU_H = 32; const GAP = 8; + // Tracks an in-flight Copy so the button can show 'Copying…' and + // block double-clicks. Save is fire-and-forget (capture goes + // through the toolbar's save flow), so it doesn't get a busy state + // — the menu just dismisses immediately. + const [busy, setBusy] = createSignal<'copy' | null>(null); + const clearSnip = (): void => { const displayId = window.pen.env.displayId(); void window.pen.snip.clear({ displayId }); @@ -291,9 +297,15 @@ function SnipActions(props: { rect: SnipRect }) { void window.pen.hub.update({ drawMode: false }); }; const onCopy = async (): Promise => { - await window.pen.snip.copy(); - clearSnip(); - exitToIdle(); + if (busy()) return; + setBusy('copy'); + try { + await window.pen.snip.copy(); + } finally { + setBusy(null); + clearSnip(); + exitToIdle(); + } }; const onSave = (): void => { // The main process's captureFocusedDisplay picks up the existing @@ -331,13 +343,15 @@ function SnipActions(props: { rect: SnipRect }) { - { - const p = revealPath(); - if (p) void window.pen.shell.openPath(p); - }} - title={revealPath() ? 'Click to reveal in folder' : ''} - >{brandLine()} +
@@ -560,12 +539,6 @@ export function ToolbarApp() { onMouseLeave={clearHint} title="Collapse" >{Icons.collapse()} -
- - -
-
{ - const p = revealPath(); - if (p) void window.pen.shell.openPath(p); - }} - > - {vertHintLine()}
@@ -842,6 +784,47 @@ export function ToolbarApp() { + + {/* ─── FOOTER ─── hover-hint on the left, plus the + rarely-touched status-dot + settings on the right. Lives + at the bottom of bar-main so it doesn't take attention + away from the tools. The hint area is also where the + 'Saved · …/lekhini-…png' reveal link surfaces. */} + {/* ─── SETTINGS DROPDOWN ─── */} diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 2d1b922..995c55a 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -157,16 +157,17 @@ html, body { pointer-events: none; } .tb-center .logo { display: inline-flex; align-items: center; } -.tb-center .hint { - font-size: 11px; - color: var(--hint); - letter-spacing: 0.02em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: color 0.15s; -} -.tb-center .hint.has-hint { color: var(--hint-accent); font-weight: 500; } +/* h-mode logo: bigger now that nothing else sits in the titlebar + center. Subtle hover scale + soft glow when drawing so it doubles + as a brand mark and a 'is this thing working' affordance. */ +.tb-center .logo.big svg, +.tb-center .logo.big img { width: 32px; height: 32px; border-radius: 7px; } +.tb-center .logo.big { + transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.tb-center .logo.big:hover { transform: scale(1.06); } +/* (Hover-hint text moved out of the titlebar into the new + .bar-footer at the bottom of the bar — see footer rules below.) */ /* ── Window controls (generic / Windows) ── */ @@ -256,7 +257,15 @@ html, body { flex-shrink: 0; } .v-brand .logo { display: inline-flex; } -.v-brand .logo svg, .v-brand .logo img { width: 24px; height: 24px; } +/* v-mode logo: even bigger now that brand strip carries only the + logo (settings + status-dot moved to footer). The vertical + toolbar is 88px wide so 40px works comfortably with breathing + room. */ +.v-brand .logo svg, .v-brand .logo img { width: 40px; height: 40px; border-radius: 9px; } +.v-brand .logo { + transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.v-brand .logo:hover { transform: scale(1.06); } .v-theme-btn { -webkit-app-region: no-drag; @@ -276,33 +285,8 @@ html, body { .v-theme-btn:hover { background: var(--winctl-hover); color: var(--text); } .v-theme-btn svg { width: 12px; height: 12px; } -/* Vertical hover-hint strip (replacement for floating tooltips, which - get clipped by the narrow toolbar window). */ -.v-hint { - -webkit-app-region: drag; - flex-shrink: 0; - height: 30px; - padding: 0 5px; - font-size: 9.5px; - letter-spacing: 0.02em; - color: var(--hint); - background: var(--titlebar-bg); - border-bottom: 1px solid var(--separator); - transition: color 0.12s; - text-align: center; - line-height: 1.15; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; - align-items: center; - justify-content: center; -} -.v-hint.has-hint { - color: var(--hint-accent); - font-weight: 600; -} +/* (The old .v-hint strip was removed — the hover-hint now lives in + the bottom .bar-footer that's shared by both orientations.) */ /* ─────────────────── SCROLL AREA ─────────────────── */ @@ -1028,10 +1012,9 @@ html, body { background: rgba(231, 76, 60, 0.12) !important; } -/* Clickable success hint in the titlebar — when revealPath is set the +/* Clickable success hint in the footer — when revealPath is set the message becomes a Reveal link. Gold accent + pointer cursor. */ -.hint.is-reveal, -.v-hint.is-reveal { +.bar-footer-hint.is-reveal { color: var(--gold) !important; cursor: pointer; -webkit-app-region: no-drag; @@ -1041,8 +1024,7 @@ html, body { text-decoration-color: rgba(216, 181, 114, 0.5); text-underline-offset: 2px; } -.hint.is-reveal:hover, -.v-hint.is-reveal:hover { +.bar-footer-hint.is-reveal:hover { text-decoration-color: var(--gold); } @@ -1074,3 +1056,70 @@ html, body { overflow: hidden; text-overflow: ellipsis; } + +/* ─────────────────── BAR FOOTER ─────────────────── */ +/* Bottom strip in both orientations carrying the hover-hint on the + left and the rarely-used status-dot + settings button on the right. + Moved here from the titlebar so the top of the toolbar is just the + logo + window controls, and incidental UI (like 'what does this + button do?' tooltips) doesn't compete with the brand mark. */ + +.bar-footer { + -webkit-app-region: drag; + flex-shrink: 0; + border-top: 1px solid var(--separator); + background: var(--footer-bg); + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; +} + +.bar-footer-hint { + flex: 1 1 auto; + min-width: 0; + font-size: 10.5px; + color: var(--hint); + letter-spacing: 0.01em; + transition: color 0.15s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bar-footer-hint.has-hint { + color: var(--hint-accent); + font-weight: 500; +} + +.bar-footer-controls { + -webkit-app-region: no-drag; + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.bar-footer-controls .winctl.footer-settings { + width: 22px; + height: 22px; +} +.bar-footer-controls .winctl.footer-settings svg { width: 13px; height: 13px; } + +/* Vertical-mode footer needs to stack — the bar is 88px wide which + isn't enough room for hint text alongside two buttons in a row. */ +.bar[data-orient='v'] .bar-footer { + flex-direction: column; + gap: 4px; + padding: 6px 4px; +} +.bar[data-orient='v'] .bar-footer-hint { + white-space: normal; + text-align: center; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + font-size: 10px; +} +.bar[data-orient='v'] .bar-footer-controls { + justify-content: center; +} From afbc462b847c9a7947fc9bb6821dbf10de6f18ff Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 03:02:15 +0530 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20collapsed=20pill=20=E2=80=94=20cli?= =?UTF-8?q?ck=20anywhere=20to=20restore,=20full=20logo=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues with the minimized state: 1. Restore was unreliable. .mini had -webkit-app-region: drag covering the whole 56px pill, with only a 30px no-drag .mini-logo span inside. Click events on drag regions are eaten by Electron, so only clicks landing exactly on the small logo restored — clicking anywhere else on the pill did nothing. 2. Logo rendered awkwardly — 30px image inside the pill plus a 22×3 pseudo-element bar at top:5 (an old drag-indicator hint that doesn't actually drag). The user described it as 'half logo'. Fix: - .mini-logo is now a button covering the whole pill except a 5px drag border around the edge — click anywhere except the border restores, while the border still drags the window. - The misleading ::after horizontal bar is gone. - Logo bumped 30→36px, object-fit: contain, pointer-events: none on the image so the parent button owns clicks (no aspect-ratio distortion if the PNG isn't perfectly square). - Subtle hover background + scale on the inner button so the click target is obvious. - MIN_SIZE 56→64 so the 36px logo + drag border has breathing room. --- src/main/windows/toolbar.ts | 4 ++- src/renderer/toolbar/App.tsx | 12 +++++++-- src/renderer/toolbar/styles.css | 47 ++++++++++++++++++++++++--------- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index 95ea62e..c38a1d4 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -13,7 +13,9 @@ let toolbar: BrowserWindow | null = null; // of drifting inward each open/close cycle. let anchorPos: { x: number; y: number } | null = null; -const MIN_SIZE = { w: 56, h: 56 }; +// Collapsed pill: square enough to be a chunky tap target and big +// enough that a 36px logo with a 5px drag border breathes. +const MIN_SIZE = { w: 64, h: 64 }; function defaultPosition(orientation: Orientation, minimized: boolean, settingsOpen: boolean) { const primary = screen.getPrimaryDisplay(); diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 3ee1b41..91f023a 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -499,8 +499,16 @@ export function ToolbarApp() { - +
+ {/* Click target is the inner span (no-drag region). The + outer .mini is a thin drag border so the pill can + still be moved without expanding. */} +
} > diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 995c55a..0d1fb36 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -574,28 +574,51 @@ html, body { } /* ─────────────────── MINIMIZED PILL ─────────────────── */ +/* Layout: outer .mini is the drag region (thin border around the + pill) so the user can still move the collapsed toolbar. The inner + .mini-logo fills almost the whole pill and is the click target + that restores — click events on drag regions are eaten by Electron, + so the previous setup (whole pill draggable, tiny logo span as the + only click target) made restoring fragile because users couldn't + reliably hit the 30px logo inside the 56px pill. */ .mini { width: 100%; height: 100%; + position: relative; + cursor: pointer; + -webkit-app-region: drag; +} +.mini-logo { + appearance: none; + background: transparent; + border: none; + padding: 0; + color: inherit; + font: inherit; + position: absolute; + inset: 5px; + -webkit-app-region: no-drag; display: flex; align-items: center; justify-content: center; + border-radius: 8px; cursor: pointer; - -webkit-app-region: drag; - position: relative; + transition: background 0.15s, transform 0.15s cubic-bezier(0.2, 0.7, 0.2, 1); } -.mini::after { - content: ''; - position: absolute; - top: 5px; - width: 22px; - height: 3px; - border-radius: 2px; - background: var(--separator); +.mini-logo:hover { + background: var(--hover); + transform: scale(1.04); +} +.mini-logo:active { transform: scale(0.97); } +.mini-logo svg, +.mini-logo img { + width: 36px; + height: 36px; + border-radius: 7px; + object-fit: contain; + pointer-events: none; /* clicks land on .mini-logo, not the image */ } -.mini-logo { -webkit-app-region: no-drag; display: inline-flex; } -.mini-logo svg, .mini-logo img { width: 30px; height: 30px; border-radius: 6px; } /* ─────────────────── SETTINGS DROPDOWN ─────────────────── */ From 2b8f08ca27ad5a45375c56c9932c3fe3628be805 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 03:08:36 +0530 Subject: [PATCH 14/14] fix: distinct pen / pencil icons + footer no longer clips after restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues: 1. The Phosphor-refresh pass made pen and pencil look almost identical — same diagonal-pencil silhouette with a small ferrule line. Added distinguishing details that read at 22 px: - Pencil gets a filled rectangular eraser cap at the wide end. - Pen gets a filled triangular nib protruding at the writing tip and a slightly bulkier barrel. 2. After collapsing and restoring, the bottom footer was clipped below the window edge until the next state change (tool selection, theme toggle, etc.) triggered reportContentSize to remeasure. Root cause: TOOLBAR_SIZES (the static post-restore window size in shared/constants.ts) was set before the footer existed and was shorter than the actual content. The renderer eventually catches up via setContentSize, but until then the footer sits outside the visible window. Fix two ways: - Bump TOOLBAR_SIZES (h.h 102→140, v.h 480→560) to cover the worst-case content height including the footer, so the window is generous from the moment it restores. - Add a second RAF after the first inside the resize effect, so transitions that unmount+remount bar-main (notably restore from minimised) get a follow-up measurement on the frame after layout fully settles. The window can shrink past these generous defaults via setContentSize — they're a floor, not a target. --- src/renderer/toolbar/App.tsx | 12 +++++++++++- src/renderer/toolbar/icons.tsx | 22 ++++++++++++++++++---- src/shared/constants.ts | 22 +++++++++++++--------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 91f023a..403c099 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -333,7 +333,17 @@ export function ToolbarApp() { void s.profile; void s.activeTool; void panelKind(); - requestAnimationFrame(reportContentSize); + // First RAF catches the common case (single-frame layout). A + // second RAF after it covers transitions where the bar-main was + // just unmounted-then-remounted (notably restore from + // minimized) — children sometimes need an extra frame to lay out + // their final size, and without this the footer would render + // clipped below the window's content-size until the next + // unrelated re-measure. + requestAnimationFrame(() => { + reportContentSize(); + requestAnimationFrame(reportContentSize); + }); }); // Refresh which side the panel sits on whenever a status panel opens diff --git a/src/renderer/toolbar/icons.tsx b/src/renderer/toolbar/icons.tsx index 1cf1ad3..1ac0574 100644 --- a/src/renderer/toolbar/icons.tsx +++ b/src/renderer/toolbar/icons.tsx @@ -23,20 +23,34 @@ const SVG = (children: JSX.Element): JSX.Element => ( ); export const Icons = { - // PencilSimple — tilted shaft with a flat tip. No ferrule clutter. + // Pencil — tilted body with a filled eraser cap on the wide end + // and a ferrule line. The eraser cap is the visual signature that + // tells it apart from the pen at a glance. pencil: () => SVG( <> + , ), - // Pen — angled barrel with a small nib square at the tip. + // Pen — slightly thicker tilted barrel with a FILLED triangular + // nib protruding at the writing tip. The filled nib is the + // signature that distinguishes it from the (eraser-capped) pencil. pen: () => SVG( <> - - + + + , ), // Highlighter — square chisel head over a tapered shaft. diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1e615c8..7cd28d2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -23,17 +23,21 @@ export const SNAP_ANGLES_DEG = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 1 // Initial sizes for the toolbar window. The renderer reports its // actual content size after mount and the window resizes to fit — so -// these values just need to be a reasonable first-paint estimate -// close to typical content size, minimizing the brief flicker before -// dynamic resize lands. +// these values just need to be a generous first-paint estimate close +// to typical content size, minimizing flicker before dynamic resize +// lands. CRITICAL: must be ≥ actual content height including the +// bottom footer, otherwise the footer is clipped below the visible +// window edge until the next state change triggers a remeasure. +// That's what bit users when they restored from the collapsed pill. // -// h.w is sized to fit teacher profile (11 tools) + actions + the -// inline color grid + right margin. h.h matches the typical bar -// height when the inline color grid is in place (it's taller than -// the icons row alone since the grid is 3 rows). +// Rough budget per orientation (sum to current values with margin): +// h: titlebar 28 + tools row 56 + footer 28 + borders 2 = ~114 +// → 140 leaves slack for taller tool rows +// v: v-controls 32 + v-brand 52 + tools ~280 + pinned ~96 + +// footer 52 + borders 2 = ~514 → 560 with slack export const TOOLBAR_SIZES = { - h: { w: 740, h: 102 }, - v: { w: 88, h: 480 }, + h: { w: 740, h: 140 }, + v: { w: 88, h: 560 }, }; // Extra space added when the settings dropdown is open.