Skip to content

Commit 2610100

Browse files
committed
fix: crisp HiDPI text via 1:1 blit, and Servo physical-resolution rendering
1 parent 202ca1a commit 2610100

8 files changed

Lines changed: 86 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [0.1.10] - 2026-05-26
4+
5+
### Fixed
6+
- HiDPI text sharpness: webview content now blits 1:1 with the surface (nearest-neighbor sampling + rounded physical sizes) instead of being bilinear-resampled
7+
- Servo HiDPI: content was rendered at 2× size and upscaled (double-applied the display scale); Servo now paints a physical-resolution buffer matching its hidpi factor
8+
- Stale/inaccurate code comments around engine render-path routing and texture color space
9+
310
## [0.1.9] - 2026-05-25
411

512
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "iced_webview_v2"
3-
version = "0.1.9"
3+
version = "0.1.10"
44
edition = "2021"
55
rust-version = "1.90.0"
66
description = "An easily embedded webview library for iced"

src/engines/blitz.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ fn create_document(
146146
scale: f32,
147147
color_scheme: ColorScheme,
148148
) -> HtmlDocument {
149-
let phys_w = (size.width as f32 * scale) as u32;
150-
let phys_h = (size.height as f32 * scale) as u32;
149+
let phys_w = (size.width as f32 * scale).round() as u32;
150+
let phys_h = (size.height as f32 * scale).round() as u32;
151151

152152
let config = DocumentConfig {
153153
base_url: if base_url.is_empty() {
@@ -218,8 +218,8 @@ fn render_view(view: &mut BlitzView, gpu: &mut GpuRasterizer) {
218218
};
219219

220220
let scale = view.scale as f64;
221-
let render_w = (w as f64 * scale) as u32;
222-
let render_h = (h as f64 * scale) as u32;
221+
let render_w = (w as f64 * scale).round() as u32;
222+
let render_h = (h as f64 * scale).round() as u32;
223223

224224
if render_w == 0 || render_h == 0 {
225225
view.last_frame = ImageInfo::blank(w, h);
@@ -398,8 +398,8 @@ impl Engine for Blitz {
398398
view.size = size;
399399
if let Some(ref mut doc) = view.document {
400400
let scale = view.scale;
401-
let phys_w = (size.width as f32 * scale) as u32;
402-
let phys_h = (size.height as f32 * scale) as u32;
401+
let phys_w = (size.width as f32 * scale).round() as u32;
402+
let phys_h = (size.height as f32 * scale).round() as u32;
403403
let mut vp = doc.viewport_mut();
404404
vp.window_size = (phys_w, phys_h);
405405
drop(vp);
@@ -417,8 +417,8 @@ impl Engine for Blitz {
417417
for view in self.views.values_mut() {
418418
view.scale = scale;
419419
if let Some(ref mut doc) = view.document {
420-
let phys_w = (view.size.width as f32 * scale) as u32;
421-
let phys_h = (view.size.height as f32 * scale) as u32;
420+
let phys_w = (view.size.width as f32 * scale).round() as u32;
421+
let phys_h = (view.size.height as f32 * scale).round() as u32;
422422
let mut vp = doc.viewport_mut();
423423
vp.window_size = (phys_w, phys_h);
424424
vp.set_hidpi_scale(scale);

src/engines/servo.rs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,20 @@ fn cursor_to_interaction(cursor: Cursor) -> Interaction {
198198
}
199199
}
200200

201+
/// Logical (device-independent) size → physical device pixels. Servo lays out
202+
/// CSS at `device_size / hidpi`, so we feed it a physical-sized buffer and a
203+
/// matching hidpi factor — otherwise content is scaled twice on HiDPI displays.
204+
fn physical_size(size: Size<u32>, scale: f32) -> PhysicalSize<u32> {
205+
PhysicalSize::new(
206+
(size.width as f32 * scale).round().max(1.0) as u32,
207+
(size.height as f32 * scale).round().max(1.0) as u32,
208+
)
209+
}
210+
201211
/// Paint a webview and capture the pixel buffer into `ImageInfo`.
202-
fn capture_frame(view: &mut ServoView, rendering_context: &SoftwareRenderingContext) {
203-
let w = view.size.width;
204-
let h = view.size.height;
212+
fn capture_frame(view: &mut ServoView, rendering_context: &SoftwareRenderingContext, scale: f32) {
213+
let phys = physical_size(view.size, scale);
214+
let (w, h) = (phys.width, phys.height);
205215
if w == 0 || h == 0 {
206216
return;
207217
}
@@ -246,20 +256,22 @@ impl Engine for Servo {
246256

247257
fn render(&mut self, _size: Size<u32>) {
248258
let rc = Rc::clone(&self.rendering_context);
259+
let scale = self.scale_factor;
249260
for view in self.views.values_mut() {
250261
if view.needs_render {
251-
capture_frame(view, &rc);
262+
capture_frame(view, &rc, scale);
252263
}
253264
}
254265
}
255266

256267
fn request_render(&mut self, id: ViewId, _size: Size<u32>) {
257268
let rc = Rc::clone(&self.rendering_context);
269+
let scale = self.scale_factor;
258270
let Some(view) = self.views.get_mut(id) else {
259271
return;
260272
};
261273
if view.needs_render {
262-
capture_frame(view, &rc);
274+
capture_frame(view, &rc, scale);
263275
}
264276
}
265277

@@ -302,18 +314,22 @@ impl Engine for Servo {
302314
let webview = builder.build();
303315
webview.focus();
304316
webview.show();
305-
webview.resize(PhysicalSize::new(w, h));
317+
webview.set_hidpi_scale_factor(
318+
euclid::Scale::<f32, DeviceIndependentPixel, DevicePixel>::new(self.scale_factor),
319+
);
320+
let phys = physical_size(size, self.scale_factor);
321+
webview.resize(phys);
306322

307323
let view = ServoView {
308324
webview,
309325
delegate_state,
310326
url: url_str,
311327
title: String::new(),
312328
cursor: Interaction::Idle,
313-
last_frame: ImageInfo::blank(w, h),
329+
last_frame: ImageInfo::blank(phys.width, phys.height),
314330
needs_render: true,
315331
size,
316-
last_cursor: DevicePoint::new(w as f32 / 2.0, h as f32 / 2.0),
332+
last_cursor: DevicePoint::new(phys.width as f32 / 2.0, phys.height as f32 / 2.0),
317333
};
318334
self.views.insert(view)
319335
}
@@ -343,7 +359,7 @@ impl Engine for Servo {
343359
}
344360

345361
fn resize(&mut self, size: Size<u32>) {
346-
let phys = PhysicalSize::new(size.width.max(1), size.height.max(1));
362+
let phys = physical_size(size, self.scale_factor);
347363
for view in self.views.values_mut() {
348364
view.size = size;
349365
view.webview.resize(phys);
@@ -362,6 +378,7 @@ impl Engine for Servo {
362378
DeviceIndependentPixel,
363379
DevicePixel,
364380
>::new(scale));
381+
view.webview.resize(physical_size(view.size, scale));
365382
view.needs_render = true;
366383
}
367384
}
@@ -376,7 +393,8 @@ impl Engine for Servo {
376393
}
377394

378395
fn handle_mouse_event(&mut self, id: ViewId, point: Point, event: mouse::Event) {
379-
let device_point = DevicePoint::new(point.x, point.y);
396+
let device_point =
397+
DevicePoint::new(point.x * self.scale_factor, point.y * self.scale_factor);
380398
let Some(view) = self.views.get_mut(id) else {
381399
return;
382400
};
@@ -419,12 +437,17 @@ impl Engine for Servo {
419437
}
420438

421439
fn scroll(&mut self, id: ViewId, delta: mouse::ScrollDelta) {
440+
let scale = self.scale_factor;
422441
let Some(view) = self.views.get_mut(id) else {
423442
return;
424443
};
425444
let (dx, dy, mode) = match delta {
426445
mouse::ScrollDelta::Lines { x, y } => (x as f64, y as f64, WheelMode::DeltaLine),
427-
mouse::ScrollDelta::Pixels { x, y } => (x as f64, y as f64, WheelMode::DeltaPixel),
446+
mouse::ScrollDelta::Pixels { x, y } => (
447+
(x * scale) as f64,
448+
(y * scale) as f64,
449+
WheelMode::DeltaPixel,
450+
),
428451
};
429452
let cursor_point = view.last_cursor;
430453
view.webview

src/webview/advanced.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ impl<'a> shader::Program<Action> for AdvancedShaderProgram<'a> {
567567
bounds: Rectangle,
568568
cursor: mouse::Cursor,
569569
) -> Option<shader::Action<Action>> {
570-
let size = Size::new(bounds.width as u32, bounds.height as u32);
570+
let size = Size::new(bounds.width.round() as u32, bounds.height.round() as u32);
571571
if state.bounds != size {
572572
state.bounds = size;
573573
return Some(shader::Action::publish(Action::Resize(size)));
@@ -712,14 +712,18 @@ where
712712
height: self.content_height,
713713
};
714714
renderer.draw_image(
715-
core_image::Image::new(self.handle.clone()).snap(true),
715+
core_image::Image::new(self.handle.clone())
716+
.snap(true)
717+
.filter_method(core_image::FilterMethod::Nearest),
716718
image_bounds,
717719
*viewport,
718720
);
719721
});
720722
} else {
721723
renderer.draw_image(
722-
core_image::Image::new(self.handle.clone()).snap(true),
724+
core_image::Image::new(self.handle.clone())
725+
.snap(true)
726+
.filter_method(core_image::FilterMethod::Nearest),
723727
bounds,
724728
*viewport,
725729
);
@@ -760,7 +764,10 @@ where
760764
shell: &mut Shell<'_, Action>,
761765
_viewport: &Rectangle,
762766
) {
763-
let size = Size::new(layout.bounds().width as u32, layout.bounds().height as u32);
767+
let size = Size::new(
768+
layout.bounds().width.round() as u32,
769+
layout.bounds().height.round() as u32,
770+
);
764771
if self.bounds != size {
765772
shell.publish(Action::Resize(size));
766773
}

src/webview/basic.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -534,8 +534,9 @@ impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> WebView
534534
let content_height = self.engine.get_content_height(id);
535535

536536
if content_height > 0.0 {
537-
// Engines that render a full-document buffer (blitz, litehtml):
538-
// use the image Handle widget with y-offset scrolling.
537+
// litehtml renders a full-document buffer: draw it with the image
538+
// Handle widget and scroll by y-offset. (blitz/servo report height 0
539+
// and take the shader path below.)
539540
WebViewWidget::new(
540541
self.engine.get_view(id),
541542
self.engine.get_cursor(id),
@@ -546,8 +547,8 @@ impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> WebView
546547
.into()
547548
} else {
548549
// Engines that manage their own scrolling and produce a viewport-
549-
// sized frame each tick (servo): use the shader widget for direct
550-
// GPU texture updates, avoiding Handle cache churn.
550+
// sized frame each tick (servo, blitz, cef): use the shader widget
551+
// for direct GPU texture updates, avoiding Handle cache churn.
551552
#[cfg(any(feature = "servo", feature = "cef", feature = "blitz"))]
552553
{
553554
use crate::webview::shader_widget::WebViewShaderProgram;
@@ -674,14 +675,18 @@ where
674675
height: self.content_height,
675676
};
676677
renderer.draw_image(
677-
core_image::Image::new(self.handle.clone()).snap(true),
678+
core_image::Image::new(self.handle.clone())
679+
.snap(true)
680+
.filter_method(core_image::FilterMethod::Nearest),
678681
image_bounds,
679682
*viewport,
680683
);
681684
});
682685
} else {
683686
renderer.draw_image(
684-
core_image::Image::new(self.handle.clone()).snap(true),
687+
core_image::Image::new(self.handle.clone())
688+
.snap(true)
689+
.filter_method(core_image::FilterMethod::Nearest),
685690
bounds,
686691
*viewport,
687692
);
@@ -724,7 +729,10 @@ where
724729
shell: &mut Shell<'_, Action>,
725730
_viewport: &Rectangle,
726731
) {
727-
let size = Size::new(layout.bounds().width as u32, layout.bounds().height as u32);
732+
let size = Size::new(
733+
layout.bounds().width.round() as u32,
734+
layout.bounds().height.round() as u32,
735+
);
728736
if self.bounds != size {
729737
self.bounds = size;
730738
shell.publish(Action::Resize(size));

src/webview/shader_widget.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ impl WebViewPipeline {
8181
}
8282
}
8383

84-
// Match the texture's color space to the surface: an sRGB target re-encodes our linear output, a linear (web-colors) target needs the sRGB bytes passed through untouched.
84+
// Match the texture format to the surface's color space. The engine produces
85+
// sRGB-encoded bytes: an sRGB surface needs an sRGB texture (decoded on sample,
86+
// re-encoded on write); a non-sRGB (web-colors) surface needs a plain texture
87+
// so the bytes pass through untouched.
8588
fn pick_texture_format(surface: wgpu::TextureFormat) -> wgpu::TextureFormat {
8689
if surface.is_srgb() {
8790
wgpu::TextureFormat::Rgba8UnormSrgb
@@ -173,10 +176,12 @@ impl shader::Pipeline for WebViewPipeline {
173176
let texture_format = pick_texture_format(format);
174177
let (texture, texture_view) = create_texture(device, 1, 1, texture_format);
175178

179+
// Buffer is rasterized at physical resolution, so it maps ~1:1 to the
180+
// surface — nearest-neighbor keeps text crisp instead of resampling.
176181
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
177182
label: Some("webview_sampler"),
178-
mag_filter: wgpu::FilterMode::Linear,
179-
min_filter: wgpu::FilterMode::Linear,
183+
mag_filter: wgpu::FilterMode::Nearest,
184+
min_filter: wgpu::FilterMode::Nearest,
180185
..Default::default()
181186
});
182187

@@ -283,7 +288,7 @@ impl<'a> shader::Program<Action> for WebViewShaderProgram<'a> {
283288
bounds: Rectangle,
284289
cursor: mouse::Cursor,
285290
) -> Option<shader::Action<Action>> {
286-
let size = Size::new(bounds.width as u32, bounds.height as u32);
291+
let size = Size::new(bounds.width.round() as u32, bounds.height.round() as u32);
287292
if state.bounds != size {
288293
state.bounds = size;
289294
return Some(shader::Action::publish(Action::Resize(size)));

0 commit comments

Comments
 (0)