svg_preview: Update preview on every buffer edit (#41270)

Closes https://github.com/zed-industries/zed/issues/39104

This fixes an issue where the preview would not work for remote buffers
in the process.

Release Notes:

- Fixed an issue where the SVG preview would not work in remote
scenarios.
- The SVG preview will now rerender on every keypress instead of only on
saves.
This commit is contained in:
Finn Evers
2025-10-29 18:49:39 +01:00
committed by GitHub
parent fbe2907919
commit 9e5438906a
9 changed files with 267 additions and 215 deletions
Generated
+1 -1
View File
@@ -16495,7 +16495,7 @@ dependencies = [
"editor",
"file_icons",
"gpui",
"multi_buffer",
"language",
"ui",
"workspace",
]
+12 -24
View File
@@ -2,14 +2,13 @@ use crate::{
AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId,
Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement,
Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task,
Window, px, swap_rgba_pa_to_bgra,
SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px,
};
use anyhow::{Context as _, Result};
use futures::{AsyncReadExt, Future};
use image::{
AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageError, ImageFormat, Rgba,
AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba,
codecs::{gif::GifDecoder, webp::WebPDecoder},
};
use smallvec::SmallVec;
@@ -160,13 +159,15 @@ pub trait StyledImage: Sized {
self
}
/// Set the object fit for the image.
/// Set a fallback function that will be invoked to render an error view should
/// the image fail to load.
fn with_fallback(mut self, fallback: impl Fn() -> AnyElement + 'static) -> Self {
self.image_style().fallback = Some(Box::new(fallback));
self
}
/// Set the object fit for the image.
/// Set a fallback function that will be invoked to render a view while the image
/// is still being loaded.
fn with_loading(mut self, loading: impl Fn() -> AnyElement + 'static) -> Self {
self.image_style().loading = Some(Box::new(loading));
self
@@ -631,7 +632,7 @@ impl Asset for ImageAssetLoader {
}
};
let data = if let Ok(format) = image::guess_format(&bytes) {
if let Ok(format) = image::guess_format(&bytes) {
let data = match format {
ImageFormat::Gif => {
let decoder = GifDecoder::new(Cursor::new(&bytes))?;
@@ -689,25 +690,12 @@ impl Asset for ImageAssetLoader {
}
};
RenderImage::new(data)
Ok(Arc::new(RenderImage::new(data)))
} else {
let pixmap =
// TODO: Can we make svgs always rescale?
svg_renderer.render_pixmap(&bytes, SvgSize::ScaleFactor(SMOOTH_SVG_SCALE_FACTOR))?;
let mut buffer =
ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
for pixel in buffer.chunks_exact_mut(4) {
swap_rgba_pa_to_bgra(pixel);
}
let mut image = RenderImage::new(SmallVec::from_elem(Frame::new(buffer), 1));
image.scale_factor = SMOOTH_SVG_SCALE_FACTOR;
image
};
Ok(Arc::new(data))
svg_renderer
.render_single_frame(&bytes, 1.0, true)
.map_err(Into::into)
}
}
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ pub use smol::Timer;
pub use style::*;
pub use styled::*;
pub use subscription::*;
use svg_renderer::*;
pub use svg_renderer::*;
pub(crate) use tab_stop::*;
pub use taffy::{AvailableSpace, LayoutId};
#[cfg(any(test, feature = "test-support"))]
+4 -8
View File
@@ -40,7 +40,7 @@ use crate::{
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun,
ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph,
ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task, TaskLabel, Window,
ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, Window,
WindowControlArea, hash, point, px, size,
};
use anyhow::Result;
@@ -1825,13 +1825,9 @@ impl Image {
ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?,
ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?,
ImageFormat::Svg => {
let pixmap = svg_renderer.render_pixmap(&self.bytes, SvgSize::ScaleFactor(1.0))?;
let buffer =
image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
.unwrap();
SmallVec::from_elem(Frame::new(buffer), 1)
return svg_renderer
.render_single_frame(&self.bytes, 1.0, false)
.map_err(Into::into);
}
};
+41 -3
View File
@@ -1,5 +1,10 @@
use crate::{AssetSource, DevicePixels, IsZero, Result, SharedString, Size};
use crate::{
AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size,
swap_rgba_pa_to_bgra,
};
use image::Frame;
use resvg::tiny_skia::Pixmap;
use smallvec::SmallVec;
use std::{
hash::Hash,
sync::{Arc, LazyLock},
@@ -15,17 +20,22 @@ pub(crate) struct RenderSvgParams {
}
#[derive(Clone)]
/// A struct holding everything necessary to render SVGs.
pub struct SvgRenderer {
asset_source: Arc<dyn AssetSource>,
usvg_options: Arc<usvg::Options<'static>>,
}
/// The size in which to render the SVG.
pub enum SvgSize {
/// An absolute size in device pixels.
Size(Size<DevicePixels>),
/// A scaling factor to apply to the size provided by the SVG.
ScaleFactor(f32),
}
impl SvgRenderer {
/// Creates a new SVG renderer with the provided asset source.
pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
static FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
let mut db = usvg::fontdb::Database::new();
@@ -54,7 +64,35 @@ impl SvgRenderer {
}
}
pub(crate) fn render(
/// Renders the given bytes into an image buffer.
pub fn render_single_frame(
&self,
bytes: &[u8],
scale_factor: f32,
to_brga: bool,
) -> Result<Arc<RenderImage>, usvg::Error> {
self.render_pixmap(
bytes,
SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
)
.map(|pixmap| {
let mut buffer =
image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
.unwrap();
if to_brga {
for pixel in buffer.chunks_exact_mut(4) {
swap_rgba_pa_to_bgra(pixel);
}
}
let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
image.scale_factor = SMOOTH_SVG_SCALE_FACTOR;
Arc::new(image)
})
}
pub(crate) fn render_alpha_mask(
&self,
params: &RenderSvgParams,
) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
@@ -80,7 +118,7 @@ impl SvgRenderer {
Ok(Some((size, alpha_mask)))
}
pub fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
let svg_size = tree.size();
let scale = match size {
+1 -1
View File
@@ -3104,7 +3104,7 @@ impl Window {
let Some(tile) =
self.sprite_atlas
.get_or_insert_with(&params.clone().into(), &mut || {
let Some((size, bytes)) = cx.svg_renderer.render(&params)? else {
let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(&params)? else {
return Ok(None);
};
Ok(Some((size, Cow::Owned(bytes))))
+1 -1
View File
@@ -15,6 +15,6 @@ path = "src/svg_preview.rs"
editor.workspace = true
file_icons.workspace = true
gpui.workspace = true
multi_buffer.workspace = true
language.workspace = true
ui.workspace = true
workspace.workspace = true
+203 -176
View File
@@ -1,13 +1,13 @@
use std::path::PathBuf;
use std::mem;
use std::sync::Arc;
use editor::Editor;
use file_icons::FileIcons;
use gpui::{
App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, IntoElement,
ParentElement, Render, Resource, RetainAllImageCache, Styled, Subscription, WeakEntity, Window,
div, img,
App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render,
RenderImage, Styled, Subscription, Task, WeakEntity, Window, div, img,
};
use multi_buffer::{Event as MultiBufferEvent, MultiBuffer};
use language::{Buffer, BufferEvent};
use ui::prelude::*;
use workspace::item::Item;
use workspace::{Pane, Workspace};
@@ -16,9 +16,10 @@ use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide};
pub struct SvgPreviewView {
focus_handle: FocusHandle,
svg_path: Option<PathBuf>,
image_cache: Entity<RetainAllImageCache>,
_buffer_subscription: Subscription,
buffer: Option<Entity<Buffer>>,
current_svg: Option<Result<Arc<RenderImage>, SharedString>>,
_refresh: Task<()>,
_buffer_subscription: Option<Subscription>,
_workspace_subscription: Option<Subscription>,
}
@@ -31,6 +32,182 @@ pub enum SvgPreviewMode {
}
impl SvgPreviewView {
pub fn new(
mode: SvgPreviewMode,
active_editor: Entity<Editor>,
workspace_handle: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<Self> {
cx.new(|cx| {
let workspace_subscription = if mode == SvgPreviewMode::Follow
&& let Some(workspace) = workspace_handle.upgrade()
{
Some(Self::subscribe_to_workspace(workspace, window, cx))
} else {
None
};
let buffer = active_editor
.read(cx)
.buffer()
.clone()
.read_with(cx, |buffer, _cx| buffer.as_singleton());
let subscription = buffer
.as_ref()
.map(|buffer| Self::create_buffer_subscription(buffer, window, cx));
let mut this = Self {
focus_handle: cx.focus_handle(),
buffer,
current_svg: None,
_buffer_subscription: subscription,
_workspace_subscription: workspace_subscription,
_refresh: Task::ready(()),
};
this.render_image(window, cx);
this
})
}
fn subscribe_to_workspace(
workspace: Entity<Workspace>,
window: &Window,
cx: &mut Context<Self>,
) -> Subscription {
cx.subscribe_in(
&workspace,
window,
move |this: &mut SvgPreviewView, workspace, event: &workspace::Event, window, cx| {
if let workspace::Event::ActiveItemChanged = event {
let workspace = workspace.read(cx);
if let Some(active_item) = workspace.active_item(cx)
&& let Some(editor) = active_item.downcast::<Editor>()
&& Self::is_svg_file(&editor, cx)
{
let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
return;
};
if this.buffer.as_ref() != Some(&buffer) {
this._buffer_subscription =
Some(Self::create_buffer_subscription(&buffer, window, cx));
this.buffer = Some(buffer);
this.render_image(window, cx);
cx.notify();
}
} else {
this.set_current(None, window, cx);
}
}
},
)
}
fn render_image(&mut self, window: &Window, cx: &mut Context<Self>) {
let Some(buffer) = self.buffer.as_ref() else {
return;
};
const SCALE_FACTOR: f32 = 1.0;
let renderer = cx.svg_renderer();
let content = buffer.read(cx).snapshot();
let background_task = cx.background_spawn(async move {
renderer.render_single_frame(content.text().as_bytes(), SCALE_FACTOR, true)
});
self._refresh = cx.spawn_in(window, async move |this, cx| {
let result = background_task.await;
this.update_in(cx, |view, window, cx| {
let current = result.map_err(|e| e.to_string().into());
view.set_current(Some(current), window, cx);
})
.ok();
});
}
fn set_current(
&mut self,
image: Option<Result<Arc<RenderImage>, SharedString>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(Ok(image)) = mem::replace(&mut self.current_svg, image) {
window.drop_image(image).ok();
}
cx.notify();
}
fn find_existing_preview_item_idx(
pane: &Pane,
editor: &Entity<Editor>,
cx: &App,
) -> Option<usize> {
let buffer_id = editor.read(cx).buffer().entity_id();
pane.items_of_type::<SvgPreviewView>()
.find(|view| {
view.read(cx)
.buffer
.as_ref()
.is_some_and(|buffer| buffer.entity_id() == buffer_id)
})
.and_then(|view| pane.index_for_item(&view))
}
pub fn resolve_active_item_as_svg_editor(
workspace: &Workspace,
cx: &mut Context<Workspace>,
) -> Option<Entity<Editor>> {
workspace
.active_item(cx)?
.act_as::<Editor>(cx)
.filter(|editor| Self::is_svg_file(&editor, cx))
}
fn create_svg_view(
mode: SvgPreviewMode,
workspace: &mut Workspace,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<SvgPreviewView> {
let workspace_handle = workspace.weak_handle();
SvgPreviewView::new(mode, editor, workspace_handle, window, cx)
}
fn create_buffer_subscription(
buffer: &Entity<Buffer>,
window: &Window,
cx: &mut Context<Self>,
) -> Subscription {
cx.subscribe_in(
buffer,
window,
move |this, _buffer, event: &BufferEvent, window, cx| match event {
BufferEvent::Edited | BufferEvent::Saved => {
this.render_image(window, cx);
}
_ => {}
},
)
}
pub fn is_svg_file(editor: &Entity<Editor>, cx: &App) -> bool {
editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
.is_some_and(|file| {
file.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
})
}
pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx)
@@ -104,154 +281,6 @@ impl SvgPreviewView {
}
});
}
fn find_existing_preview_item_idx(
pane: &Pane,
editor: &Entity<Editor>,
cx: &App,
) -> Option<usize> {
let editor_path = Self::get_svg_path(editor.read(cx).buffer(), cx);
pane.items_of_type::<SvgPreviewView>()
.find(|view| {
let view_read = view.read(cx);
view_read.svg_path.is_some() && view_read.svg_path == editor_path
})
.and_then(|view| pane.index_for_item(&view))
}
pub fn resolve_active_item_as_svg_editor(
workspace: &Workspace,
cx: &mut Context<Workspace>,
) -> Option<Entity<Editor>> {
let editor = workspace.active_item(cx)?.act_as::<Editor>(cx)?;
if Self::is_svg_file(&editor, cx) {
Some(editor)
} else {
None
}
}
fn create_svg_view(
mode: SvgPreviewMode,
workspace: &mut Workspace,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<SvgPreviewView> {
let workspace_handle = workspace.weak_handle();
SvgPreviewView::new(mode, editor, workspace_handle, window, cx)
}
pub fn new(
mode: SvgPreviewMode,
active_editor: Entity<Editor>,
workspace_handle: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<Self> {
cx.new(|cx| {
let image_cache = RetainAllImageCache::new(cx);
let buffer = active_editor.read(cx).buffer();
let svg_path = Self::get_svg_path(buffer, cx);
let subscription = Self::create_buffer_subscription(&buffer.clone(), window, cx);
// Subscribe to workspace active item changes to follow SVG files
let workspace_subscription = if mode == SvgPreviewMode::Follow {
workspace_handle.upgrade().map(|workspace_handle| {
cx.subscribe_in(
&workspace_handle,
window,
|this: &mut SvgPreviewView,
workspace,
event: &workspace::Event,
window,
cx| {
if let workspace::Event::ActiveItemChanged = event {
let workspace_read = workspace.read(cx);
if let Some(active_item) = workspace_read.active_item(cx)
&& let Some(editor) = active_item.downcast::<Editor>()
&& Self::is_svg_file(&editor, cx)
{
let buffer = editor.read(cx).buffer();
let new_path = Self::get_svg_path(&buffer, cx);
if this.svg_path != new_path {
this.svg_path = new_path;
this._buffer_subscription =
Self::create_buffer_subscription(
&buffer.clone(),
window,
cx,
);
cx.notify();
}
}
}
},
)
})
} else {
None
};
Self {
focus_handle: cx.focus_handle(),
svg_path,
image_cache,
_buffer_subscription: subscription,
_workspace_subscription: workspace_subscription,
}
})
}
fn create_buffer_subscription(
active_buffer: &Entity<MultiBuffer>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Subscription {
cx.subscribe_in(
active_buffer,
window,
|this: &mut SvgPreviewView, buffer, event: &MultiBufferEvent, window, cx| {
let potential_path_change = event == &MultiBufferEvent::FileHandleChanged;
if event == &MultiBufferEvent::Saved || potential_path_change {
// Remove cached image to force reload
if let Some(svg_path) = &this.svg_path {
let resource = Resource::Path(svg_path.clone().into());
this.image_cache.update(cx, |cache, cx| {
cache.remove(&resource, window, cx);
});
}
if potential_path_change {
this.svg_path = Self::get_svg_path(buffer, cx);
}
cx.notify();
}
},
)
}
pub fn is_svg_file(editor: &Entity<Editor>, cx: &App) -> bool {
let buffer = editor.read(cx).buffer().read(cx);
if let Some(buffer) = buffer.as_singleton()
&& let Some(file) = buffer.read(cx).file()
{
return file
.path()
.extension()
.map(|ext| ext.eq_ignore_ascii_case("svg"))
.unwrap_or(false);
}
false
}
fn get_svg_path(buffer: &Entity<MultiBuffer>, cx: &App) -> Option<PathBuf> {
let buffer = buffer.read(cx).as_singleton()?;
let file = buffer.read(cx).file()?;
let local_file = file.as_local()?;
Some(local_file.abs_path(cx))
}
}
impl Render for SvgPreviewView {
@@ -265,20 +294,19 @@ impl Render for SvgPreviewView {
.flex()
.justify_center()
.items_center()
.child(if let Some(svg_path) = &self.svg_path {
img(ImageSource::from(svg_path.clone()))
.image_cache(&self.image_cache)
.max_w_full()
.max_h_full()
.with_fallback(|| {
div()
.map(|this| match self.current_svg.clone() {
Some(Ok(image)) => {
this.child(img(image).max_w_full().max_h_full().with_fallback(|| {
h_flex()
.p_4()
.child("Failed to load SVG file")
.gap_2()
.child(Icon::new(IconName::Warning))
.child("Failed to load SVG image")
.into_any_element()
})
.into_any_element()
} else {
div().p_4().child("No SVG file selected").into_any_element()
}))
}
Some(Err(e)) => this.child(div().p_4().child(e).into_any_element()),
None => this.child(div().p_4().child("No SVG file selected")),
})
}
}
@@ -295,20 +323,19 @@ impl Item for SvgPreviewView {
type Event = ();
fn tab_icon(&self, _window: &Window, cx: &App) -> Option<Icon> {
// Use the same icon as SVG files in the file tree
self.svg_path
self.buffer
.as_ref()
.and_then(|svg_path| FileIcons::get_icon(svg_path, cx))
.and_then(|buffer| buffer.read(cx).file())
.and_then(|file| FileIcons::get_icon(file.path().as_std_path(), cx))
.map(Icon::from_path)
.or_else(|| Some(Icon::new(IconName::Image)))
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
self.svg_path
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
self.buffer
.as_ref()
.and_then(|svg_path| svg_path.file_name())
.map(|name| name.to_string_lossy())
.map(|name| format!("Preview {}", name).into())
.and_then(|svg_path| svg_path.read(cx).file())
.map(|name| format!("Preview {}", name.file_name(cx)).into())
.unwrap_or_else(|| "SVG Preview".into())
}
+3
View File
@@ -41,6 +41,9 @@ const DEFAULT_FILTERS: &[(&str, log::LevelFilter)] = &[
("blade_graphics", log::LevelFilter::Warn),
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
("naga::back::spv::writer", log::LevelFilter::Warn),
// usvg prints a lot of warnings on rendering an SVG with partial errors, which
// can happen a lot with the SVG preview
("usvg::parser::style", log::LevelFilter::Error),
];
pub fn init_env_filter(filter: env_config::EnvFilter) {