Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Fix DPI display scaling to make overlays render at the native resolution
We use a `<canvas>` element for displaying our overlays on top of the artwork. But for users with a DPI display scaling ratio other than 100%, this won't be drawn correctly. Then the rounding in the overlay drawing code, which is designed to ensure that overlays are rendered crisply without antialiasing fuzziness, will also need to be updated to ensure that continues to work at a new pixel grid ratio/alignment.


Discussion / hints:
I want to work on this task, would you be able to give some starting point in the codebase if I'm possibly looking at the wrong place? Right now I'm thinking about making a change somewhere around https://github.com/GraphiteEditor/Graphite/blob/master/frontend/src/components/floating-menus/EyedropperPreview.svelte#L17
![image](https://github.com/GraphiteEditor/Graphite/assets/39400946/96bc878e-39dc-4b49-93f4-8382b3ef82f1)



@CheeksTheGeek You'll want to look in `editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs`.

Fix patch:
diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message.rs b/editor/src/messages/portfolio/document/overlays/overlays_message.rs
index 408126bd2a..6716e17db3 100644
--- a/editor/src/messages/portfolio/document/overlays/overlays_message.rs
+++ b/editor/src/messages/portfolio/document/overlays/overlays_message.rs
@@ -5,7 +5,7 @@ use crate::messages::prelude::*;
 #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
 pub enum OverlaysMessage {
 	Draw,
-
+	SetDevicePixelRatio { ratio: f64 },
 	// Serde functionality isn't used but is required by the message system macros
 	AddProvider(#[serde(skip, default = "empty_provider")] OverlayProvider),
 	RemoveProvider(#[serde(skip, default = "empty_provider")] OverlayProvider),
diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs
index 2ea0467c51..17d6e38728 100644
--- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs
+++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs
@@ -11,6 +11,7 @@ pub struct OverlaysMessageHandler {
 	pub overlay_providers: HashSet<OverlayProvider>,
 	canvas: Option<web_sys::HtmlCanvasElement>,
 	context: Option<web_sys::CanvasRenderingContext2d>,
+	device_pixel_ratio: Option<f64>,
 }
 
 impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessageHandler {
@@ -22,6 +23,7 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
 			OverlaysMessage::Draw => {
 				use super::utility_functions::overlay_canvas_element;
 				use super::utility_types::OverlayContext;
+				use glam::{DAffine2, DVec2};
 				use wasm_bindgen::JsCast;
 
 				let canvas = match &self.canvas {
@@ -39,17 +41,24 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
 
 				let size = ipp.viewport_bounds.size().as_uvec2();
 
+				let device_pixel_ratio = self.device_pixel_ratio.unwrap_or(1.);
+
+				let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(device_pixel_ratio)).to_cols_array();
+				let _ = context.set_transform(a, b, c, d, e, f);
 				context.clear_rect(0., 0., ipp.viewport_bounds.size().x, ipp.viewport_bounds.size().y);
+				let _ = context.reset_transform();
 
 				if overlays_visible {
 					responses.add(DocumentMessage::GridOverlays(OverlayContext {
 						render_context: context.clone(),
 						size: size.as_dvec2(),
+						device_pixel_ratio,
 					}));
 					for provider in &self.overlay_providers {
 						responses.add(provider(OverlayContext {
 							render_context: context.clone(),
 							size: size.as_dvec2(),
+							device_pixel_ratio,
 						}));
 					}
 				}
@@ -61,6 +70,10 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
 					self.canvas, self.context
 				);
 			}
+			OverlaysMessage::SetDevicePixelRatio { ratio } => {
+				self.device_pixel_ratio = Some(ratio);
+				responses.add(OverlaysMessage::Draw);
+			}
 			OverlaysMessage::AddProvider(message) => {
 				self.overlay_providers.insert(message);
 			}
diff --git a/editor/src/messages/portfolio/document/overlays/utility_types.rs b/editor/src/messages/portfolio/document/overlays/utility_types.rs
index 4be7a567d7..b93f296f54 100644
--- a/editor/src/messages/portfolio/document/overlays/utility_types.rs
+++ b/editor/src/messages/portfolio/document/overlays/utility_types.rs
@@ -26,6 +26,9 @@ pub struct OverlayContext {
 	#[specta(skip)]
 	pub render_context: web_sys::CanvasRenderingContext2d,
 	pub size: DVec2,
+	// The device pixel ratio is a property provided by the browser window and is the CSS pixel size divided by the physical monitor's pixel size.
+	// It allows better pixel density of visualizations on high-DPI displays where the OS display scaling is not 100%, or where the browser is zoomed.
+	pub device_pixel_ratio: f64,
 }
 // Message hashing isn't used but is required by the message system macros
 impl core::hash::Hash for OverlayContext {
@@ -38,6 +41,8 @@ impl OverlayContext {
 	}
 
 	pub fn dashed_quad(&mut self, quad: Quad, color_fill: Option<&str>, dash_width: Option<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
+		self.start_dpi_aware_transform();
+
 		// Set the dash pattern
 		if let Some(dash_width) = dash_width {
 			let dash_gap_width = dash_gap_width.unwrap_or(1.);
@@ -82,6 +87,8 @@ impl OverlayContext {
 		if dash_offset.is_some() && dash_offset != Some(0.) {
 			self.render_context.set_line_dash_offset(0.);
 		}
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn line(&mut self, start: DVec2, end: DVec2, color: Option<&str>) {
@@ -89,6 +96,8 @@ impl OverlayContext {
 	}
 
 	pub fn dashed_line(&mut self, start: DVec2, end: DVec2, color: Option<&str>, dash_width: Option<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
+		self.start_dpi_aware_transform();
+
 		// Set the dash pattern
 		if let Some(dash_width) = dash_width {
 			let dash_gap_width = dash_gap_width.unwrap_or(1.);
@@ -127,9 +136,13 @@ impl OverlayContext {
 		if dash_offset.is_some() && dash_offset != Some(0.) {
 			self.render_context.set_line_dash_offset(0.);
 		}
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn manipulator_handle(&mut self, position: DVec2, selected: bool, color: Option<&str>) {
+		self.start_dpi_aware_transform();
+
 		let position = position.round() - DVec2::splat(0.5);
 
 		self.render_context.begin_path();
@@ -142,6 +155,8 @@ impl OverlayContext {
 		self.render_context.set_stroke_style_str(color.unwrap_or(COLOR_OVERLAY_BLUE));
 		self.render_context.fill();
 		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn manipulator_anchor(&mut self, position: DVec2, selected: bool, color: Option<&str>) {
@@ -150,6 +165,23 @@ impl OverlayContext {
 		self.square(position, None, Some(color_fill), Some(color_stroke));
 	}
 
+	/// Transforms the canvas context to adjust for DPI scaling
+	///
+	/// Overwrites all existing tranforms. This operation can be reversed with [`Self::reset_transform`].
+	fn start_dpi_aware_transform(&self) {
+		let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(self.device_pixel_ratio)).to_cols_array();
+		self.render_context
+			.set_transform(a, b, c, d, e, f)
+			.expect("transform should be able to be set to be able to account for DPI");
+	}
+
+	/// Un-transforms the Canvas context to adjust for DPI scaling
+	///
+	/// Warning: this function doesn't only reset the DPI scaling adjustment, it resets the entire transform.
+	fn end_dpi_aware_transform(&self) {
+		self.render_context.reset_transform().expect("transform should be able to be reset to be able to account for DPI");
+	}
+
 	pub fn square(&mut self, position: DVec2, size: Option<f64>, color_fill: Option<&str>, color_stroke: Option<&str>) {
 		let size = size.unwrap_or(MANIPULATOR_GROUP_MARKER_SIZE);
 		let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
@@ -158,12 +190,16 @@ impl OverlayContext {
 		let position = position.round() - DVec2::splat(0.5);
 		let corner = position - DVec2::splat(size) / 2.;
 
+		self.start_dpi_aware_transform();
+
 		self.render_context.begin_path();
 		self.render_context.rect(corner.x, corner.y, size, size);
 		self.render_context.set_fill_style_str(color_fill);
 		self.render_context.set_stroke_style_str(color_stroke);
 		self.render_context.fill();
 		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn pixel(&mut self, position: DVec2, color: Option<&str>) {
@@ -173,22 +209,31 @@ impl OverlayContext {
 		let position = position.round() - DVec2::splat(0.5);
 		let corner = position - DVec2::splat(size) / 2.;
 
+		self.start_dpi_aware_transform();
+
 		self.render_context.begin_path();
 		self.render_context.rect(corner.x, corner.y, size, size);
 		self.render_context.set_fill_style_str(color_fill);
 		self.render_context.fill();
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn circle(&mut self, position: DVec2, radius: f64, color_fill: Option<&str>, color_stroke: Option<&str>) {
 		let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
 		let color_stroke = color_stroke.unwrap_or(COLOR_OVERLAY_BLUE);
 		let position = position.round();
+
+		self.start_dpi_aware_transform();
+
 		self.render_context.begin_path();
 		self.render_context.arc(position.x, position.y, radius, 0., TAU).expect("Failed to draw the circle");
 		self.render_context.set_fill_style_str(color_fill);
 		self.render_context.set_stroke_style_str(color_stroke);
 		self.render_context.fill();
 		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn draw_arc(&mut self, center: DVec2, radius: f64, start_from: f64, end_at: f64) {
@@ -252,6 +297,8 @@ impl OverlayContext {
 	pub fn pivot(&mut self, position: DVec2) {
 		let (x, y) = (position.round() - DVec2::splat(0.5)).into();
 
+		self.start_dpi_aware_transform();
+
 		// Circle
 
 		self.render_context.begin_path();
@@ -276,9 +323,15 @@ impl OverlayContext {
 		self.render_context.move_to(x, y - crosshair_radius);
 		self.render_context.line_to(x, y + crosshair_radius);
 		self.render_context.stroke();
+
+		self.render_context.set_line_cap("butt");
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn outline_vector(&mut self, vector_data: &VectorData, transform: DAffine2) {
+		self.start_dpi_aware_transform();
+
 		self.render_context.begin_path();
 		let mut last_point = None;
 		for (_, bezier, start_id, end_id) in vector_data.segment_bezier_iter() {
@@ -290,16 +343,24 @@ impl OverlayContext {
 
 		self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
 		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn outline_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
+		self.start_dpi_aware_transform();
+
 		self.render_context.begin_path();
 		self.bezier_command(bezier, transform, true);
 		self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
 		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
 	}
 
 	fn bezier_command(&self, bezier: Bezier, transform: DAffine2, move_to: bool) {
+		self.start_dpi_aware_transform();
+
 		let Bezier { start, end, handles } = bezier.apply_transformation(|point| transform.transform_point2(point));
 		if move_to {
 			self.render_context.move_to(start.x, start.y);
@@ -310,9 +371,13 @@ impl OverlayContext {
 			bezier_rs::BezierHandles::Quadratic { handle } => self.render_context.quadratic_curve_to(handle.x, handle.y, end.x, end.y),
 			bezier_rs::BezierHandles::Cubic { handle_start, handle_end } => self.render_context.bezier_curve_to(handle_start.x, handle_start.y, handle_end.x, handle_end.y, end.x, end.y),
 		}
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn outline(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2) {
+		self.start_dpi_aware_transform();
+
 		self.render_context.begin_path();
 		for subpath in subpaths {
 			let subpath = subpath.borrow();
@@ -359,6 +424,8 @@ impl OverlayContext {
 
 		self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
 		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
 	}
 
 	pub fn get_width(&self, text: &str) -> f64 {
@@ -378,7 +445,7 @@ impl OverlayContext {
 			Pivot::End => -padding,
 		};
 
-		let [a, b, c, d, e, f] = (transform * DAffine2::from_translation(DVec2::new(x, y))).to_cols_array();
+		let [a, b, c, d, e, f] = (DAffine2::from_scale(DVec2::splat(self.device_pixel_ratio)) * transform * DAffine2::from_translation(DVec2::new(x, y))).to_cols_array();
 		self.render_context.set_transform(a, b, c, d, e, f).expect("Failed to rotate the render context to the specified angle");
 
 		if let Some(background) = background_color {
diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte
index 9101df87c3..ece02f4bdd 100644
--- a/frontend/src/components/panels/Document.svelte
+++ b/frontend/src/components/panels/Document.svelte
@@ -75,7 +75,8 @@
 	let canvasSvgWidth: number | undefined = undefined;
 	let canvasSvgHeight: number | undefined = undefined;
 
-	// Used to set the canvas rendering dimensions.
+	let devicePixelRatio: number | undefined;
+
 	// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
 	$: canvasWidthRoundedToEven = canvasSvgWidth && (canvasSvgWidth % 2 === 1 ? canvasSvgWidth + 1 : canvasSvgWidth);
 	$: canvasHeightRoundedToEven = canvasSvgHeight && (canvasSvgHeight % 2 === 1 ? canvasSvgHeight + 1 : canvasSvgHeight);
@@ -84,6 +85,13 @@
 	$: canvasWidthCSS = canvasWidthRoundedToEven ? `${canvasWidthRoundedToEven}px` : "100%";
 	$: canvasHeightCSS = canvasHeightRoundedToEven ? `${canvasHeightRoundedToEven}px` : "100%";
 
+	$: canvasWidthScaled = canvasSvgWidth && devicePixelRatio && Math.floor(canvasSvgWidth * devicePixelRatio);
+	$: canvasHeightScaled = canvasSvgHeight && devicePixelRatio && Math.floor(canvasSvgHeight * devicePixelRatio);
+
+	// Used to set the canvas rendering dimensions.
+	$: canvasWidthScaledRoundedToEven = canvasWidthScaled && (canvasWidthScaled % 2 === 1 ? canvasWidthScaled + 1 : canvasWidthScaled);
+	$: canvasHeightScaledRoundedToEven = canvasHeightScaled && (canvasHeightScaled % 2 === 1 ? canvasHeightScaled + 1 : canvasHeightScaled);
+
 	$: toolShelfTotalToolsAndSeparators = ((layoutGroup) => {
 		if (!isWidgetSpanRow(layoutGroup)) return undefined;
 
@@ -362,6 +370,22 @@
 	}
 
 	onMount(() => {
+		// Not compatible with Safari:
+		// <https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#browser_compatibility>
+		// <https://bugs.webkit.org/show_bug.cgi?id=124862>
+		let removeUpdatePixelRatio: (() => void) | undefined = undefined;
+		const updatePixelRatio = () => {
+			removeUpdatePixelRatio?.();
+			const mediaQueryList = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
+			// The event is one-time use, so we have to set up a new listener and remove the old one every time
+			mediaQueryList.addEventListener("change", updatePixelRatio);
+			removeUpdatePixelRatio = () => mediaQueryList.removeEventListener("change", updatePixelRatio);
+
+			devicePixelRatio = window.devicePixelRatio;
+			editor.handle.setDevicePixelRatio(devicePixelRatio);
+		};
+		updatePixelRatio();
+
 		// Update rendered SVGs
 		editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
 			await tick();
@@ -508,7 +532,14 @@
 								<div bind:this={textInput} style:transform="matrix({textInputMatrix})" on:scroll={preventTextEditingScroll} />
 							{/if}
 						</div>
-						<canvas class="overlays" width={canvasWidthRoundedToEven} height={canvasHeightRoundedToEven} style:width={canvasWidthCSS} style:height={canvasHeightCSS} data-overlays-canvas>
+						<canvas
+							class="overlays"
+							width={canvasWidthScaledRoundedToEven}
+							height={canvasHeightScaledRoundedToEven}
+							style:width={canvasWidthCSS}
+							style:height={canvasHeightCSS}
+							data-overlays-canvas
+						>
 						</canvas>
 					</div>
 					<div class="graph-view" class:open={$document.graphViewOverlayOpen} style:--fade-artwork={`${$document.fadeArtwork}%`} data-graph>
diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs
index 425fa4837b..19b7550863 100644
--- a/frontend/wasm/src/editor_api.rs
+++ b/frontend/wasm/src/editor_api.rs
@@ -352,6 +352,13 @@ impl EditorHandle {
 		self.dispatch(message);
 	}
 
+	/// Inform the overlays system of the current device pixel ratio
+	#[wasm_bindgen(js_name = setDevicePixelRatio)]
+	pub fn set_device_pixel_ratio(&self, ratio: f64) {
+		let message = OverlaysMessage::SetDevicePixelRatio { ratio };
+		self.dispatch(message);
+	}
+
 	/// Mouse movement within the screenspace bounds of the viewport
 	#[wasm_bindgen(js_name = onMouseMove)]
 	pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {


Test patch: