diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c4950f0f46..e99bd185af 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -252,6 +252,7 @@ async function applyUiScale(scale: number) { try { const { getCurrentWebview } = await import("@tauri-apps/api/webview"); await getCurrentWebview().setZoom(scale); + window.dispatchEvent(new CustomEvent("dbx:ui-scale-applied", { detail: { scale } })); } catch (error) { console.warn("[DBX] Failed to apply UI scale", { scale, error }); } diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e4e037a9bb..f4cc95648a 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -3337,14 +3337,22 @@ const canvasViewportWidth = ref(0); const canvasViewportHeight = ref(0); const canvasScrollTop = ref(0); const canvasHoverCell = ref<{ rowIndex: number; visibleColIdx: number } | null>(null); +const canvasDevicePixelRatio = ref(typeof window === "undefined" ? 1 : window.devicePixelRatio || 1); +const canvasBackingPixelRatio = computed(() => + Math.min(4, Math.max(1, canvasDevicePixelRatio.value * settingsStore.editorSettings.uiScale)), +); const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas"); const canvasContentHeight = computed(() => Math.max(1, displayRowCount.value * CANVAS_DATA_GRID_ROW_HEIGHT)); const canvasRenderStyleKey = computed( - () => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${isDark.value}`, + () => + `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}`, ); const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5; const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40; let canvasResizeObserver: ResizeObserver | null = null; +let canvasPixelRatioMediaQuery: MediaQueryList | null = null; +let canvasPixelRatioMediaQueryCleanup: (() => void) | null = null; +let canvasPixelRatioFrame = 0; let canvasDrawFrame = 0; let dataGridIsActive = true; @@ -3376,6 +3384,39 @@ function syncCanvasViewport() { drawCanvasGridNow(); } +function currentCanvasDevicePixelRatio(): number { + return typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1); +} + +function scheduleCanvasPixelRatioRefresh() { + if (!dataGridIsActive || canvasPixelRatioFrame) return; + canvasPixelRatioFrame = requestAnimationFrame(() => { + canvasPixelRatioFrame = 0; + const next = currentCanvasDevicePixelRatio(); + if (Math.abs(next - canvasDevicePixelRatio.value) > 0.001) { + canvasDevicePixelRatio.value = next; + attachCanvasPixelRatioWatcher(); + } + syncCanvasViewport(); + }); +} + +function attachCanvasPixelRatioWatcher() { + canvasPixelRatioMediaQueryCleanup?.(); + canvasPixelRatioMediaQueryCleanup = null; + canvasPixelRatioMediaQuery = null; + if (!dataGridIsActive || !useCanvasGridRows.value || typeof window === "undefined" || !window.matchMedia) return; + + const ratio = currentCanvasDevicePixelRatio(); + canvasDevicePixelRatio.value = ratio; + canvasPixelRatioMediaQuery = window.matchMedia(`(resolution: ${ratio}dppx)`); + const onChange = () => scheduleCanvasPixelRatioRefresh(); + canvasPixelRatioMediaQuery.addEventListener("change", onChange); + canvasPixelRatioMediaQueryCleanup = () => { + canvasPixelRatioMediaQuery?.removeEventListener("change", onChange); + }; +} + function attachCanvasResizeObserver() { canvasResizeObserver?.disconnect(); canvasResizeObserver = null; @@ -3383,6 +3424,7 @@ function attachCanvasResizeObserver() { if (!useCanvasGridRows.value) return; const scroller = canvasScrollerElement(); if (!scroller) return; + attachCanvasPixelRatioWatcher(); syncCanvasViewport(); if (typeof ResizeObserver === "undefined") return; canvasResizeObserver = new ResizeObserver(syncCanvasViewport); @@ -3723,6 +3765,7 @@ function drawCanvasGrid() { scroller, width: Math.max(1, canvasViewportWidth.value || scroller.clientWidth), height: Math.max(1, canvasViewportHeight.value || scroller.clientHeight), + pixelRatio: canvasBackingPixelRatio.value, isDark: isDark.value, styleKey: canvasRenderStyleKey.value, rowCount: displayRowCount.value, @@ -3760,6 +3803,8 @@ watch( currentSearchMatch, isDark, canvasRenderStyleKey, + canvasDevicePixelRatio, + canvasBackingPixelRatio, isScrolling, hoveredDetailCell, detailCell, @@ -3774,6 +3819,13 @@ function pauseCanvasGridWork() { dataGridIsActive = false; canvasResizeObserver?.disconnect(); canvasResizeObserver = null; + canvasPixelRatioMediaQueryCleanup?.(); + canvasPixelRatioMediaQueryCleanup = null; + canvasPixelRatioMediaQuery = null; + if (canvasPixelRatioFrame) { + cancelAnimationFrame(canvasPixelRatioFrame); + canvasPixelRatioFrame = 0; + } if (canvasDrawFrame) { cancelAnimationFrame(canvasDrawFrame); canvasDrawFrame = 0; @@ -3787,8 +3839,20 @@ function resumeCanvasGridWork() { onMounted(resumeCanvasGridWork); onActivated(resumeCanvasGridWork); +onMounted(() => { + if (typeof window === "undefined") return; + window.addEventListener("resize", scheduleCanvasPixelRatioRefresh); + window.visualViewport?.addEventListener("resize", scheduleCanvasPixelRatioRefresh); + window.addEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh); +}); onDeactivated(pauseCanvasGridWork); -onUnmounted(pauseCanvasGridWork); +onUnmounted(() => { + pauseCanvasGridWork(); + if (typeof window === "undefined") return; + window.removeEventListener("resize", scheduleCanvasPixelRatioRefresh); + window.visualViewport?.removeEventListener("resize", scheduleCanvasPixelRatioRefresh); + window.removeEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh); +}); function setRowStatusFilter(value: string) { rowStatusFilter.value = value as RowStatusFilter; diff --git a/apps/desktop/src/lib/canvasDataGridRenderer.ts b/apps/desktop/src/lib/canvasDataGridRenderer.ts index df03ae1309..707161bb33 100644 --- a/apps/desktop/src/lib/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/canvasDataGridRenderer.ts @@ -43,6 +43,7 @@ export interface DrawCanvasDataGridOptions { scroller: HTMLElement; width: number; height: number; + pixelRatio?: number; isDark: boolean; styleKey?: string; rowCount: number; @@ -156,8 +157,12 @@ function firstVisibleColumn(offsets: number[], contentStart: number): number { return low; } -function crispCanvasLine(value: number): number { - return Math.round(value) + 0.5; +function alignCanvasPixel(value: number, dpr: number): number { + return Math.round(value * dpr) / dpr; +} + +function crispCanvasLine(value: number, dpr: number): number { + return alignCanvasPixel(value, dpr) + 0.5 / dpr; } function resolveCanvasRenderState(canvas: HTMLCanvasElement, isDark: boolean, styleKey?: string): CanvasRenderState { @@ -236,9 +241,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { cellIsSelected, cellCanHover, } = options; - const dpr = window.devicePixelRatio || 1; - const pixelWidth = Math.floor(width * dpr); - const pixelHeight = Math.floor(height * dpr); + const dpr = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1); + const pixelWidth = Math.max(1, Math.ceil(width * dpr)); + const pixelHeight = Math.max(1, Math.ceil(height * dpr)); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; @@ -251,6 +256,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, width, height); const { @@ -279,8 +285,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { const firstCol = firstVisibleColumn(offsets, contentStart); const columnOffset = offsets[firstCol] ?? 0; const paintSearchMatches = !isScrolling && searchMatchKeys.size > 0; - const rowNumberBorderX = crispCanvasLine(rowNumberWidth - 1); - const rowNumberTextX = Math.max(0, rowNumberWidth - 1) / 2; + const rowNumberBorderX = crispCanvasLine(rowNumberWidth - 1, dpr); + const rowNumberTextX = alignCanvasPixel(Math.max(0, rowNumberWidth - 1) / 2, dpr); + const rowTextOffsetY = alignCanvasPixel(CANVAS_DATA_GRID_ROW_HEIGHT / 2, dpr); for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) { const item = rowAt(rowIndex); @@ -340,7 +347,8 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.fillStyle = rowNumberText; ctx.font = item.status === "new" || item.status === "edited" || isRowSelected(item.id) ? semiboldFont : normalFont; ctx.textAlign = "center"; - ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2); + const textY = alignCanvasPixel(y + rowTextOffsetY, dpr); + ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, textY); ctx.font = normalFont; let x = rowNumberWidth + columnOffset - scrollLeft; @@ -411,20 +419,20 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground; ctx.font = value === null ? italicFont : typeof value === "number" ? tabularFont : normalFont; setCanvasNumericVariant(ctx, typeof value === "number" ? "tabular-nums" : "normal"); - const textLeft = x + 12; + const textLeft = alignCanvasPixel(x + 12, dpr); const paddedMaxWidth = Math.max(0, x + colWidth - textLeft - 12); const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx; const displayText = isEditingThisCell ? "" : formatCell(value, actualColIdx); const needsTruncation = ctx.measureText(displayText).width > paddedMaxWidth; const textMaxWidth = needsTruncation ? Math.max(0, x + colWidth - textLeft) : paddedMaxWidth; const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, textMaxWidth); - ctx.fillText(text, textLeft, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2); + ctx.fillText(text, textLeft, textY); if (item.isDeleted && text) { const textWidth = Math.min(ctx.measureText(text).width, textMaxWidth); ctx.strokeStyle = theme.foreground; ctx.beginPath(); - ctx.moveTo(textLeft, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2); - ctx.lineTo(textLeft + textWidth, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2); + ctx.moveTo(textLeft, textY); + ctx.lineTo(alignCanvasPixel(textLeft + textWidth, dpr), textY); ctx.stroke(); } ctx.restore(); @@ -433,7 +441,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.strokeStyle = theme.border; ctx.beginPath(); - const columnBorderX = crispCanvasLine(x + colWidth - 1); + const columnBorderX = crispCanvasLine(x + colWidth - 1, dpr); ctx.moveTo(columnBorderX, y); ctx.lineTo(columnBorderX, y + CANVAS_DATA_GRID_ROW_HEIGHT); ctx.stroke(); @@ -470,7 +478,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { } ctx.strokeStyle = theme.border; ctx.beginPath(); - const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1); + const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, dpr); ctx.moveTo(0, rowBorderY); ctx.lineTo(width, rowBorderY); ctx.stroke();