From 59738a777684a83cf9ded9646719887022e0f675 Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Thu, 22 Jan 2026 03:18:30 +0530 Subject: [PATCH] Support zooming and panning in the image viewer (#43944) Implemented Pan and Zoom on the Image Viewer. Demo: https://github.com/user-attachments/assets/855bafe8-fdc2-4945-9bfb-e48382264806 Closes #9584 Release Notes: - Add zoom in/out, reset, fit-to-view and zoom-to-actual actions - Support scroll-wheel zoom with modifier, click-and-drag panning - Renders a zoom percentage overlay. - ImageView Toolbar Added to control zoom in/out and fit view. --------- Co-authored-by: MrSubidubi --- assets/keymaps/default-linux.json | 11 + assets/keymaps/default-macos.json | 12 + assets/keymaps/default-windows.json | 11 + crates/image_viewer/src/image_viewer.rs | 613 ++++++++++++++++++++---- crates/zed/src/zed.rs | 3 + 5 files changed, 569 insertions(+), 81 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 3f80633917b3becdc485c1c70a23e7d279f6eacf..ad23d7051e92751bb6dc1b7df0dace076ee324bd 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1342,6 +1342,17 @@ "ctrl-shift-i": "branch_picker::FilterRemotes", }, }, + { + "context": "ImageViewer", + "bindings": { + "ctrl-=": "image_viewer::ZoomIn", + "ctrl-+": "image_viewer::ZoomIn", + "ctrl--": "image_viewer::ZoomOut", + "ctrl-0": "image_viewer::ResetZoom", + "ctrl-1": "image_viewer::ZoomToActualSize", + "ctrl-shift-0": "image_viewer::FitToView", + }, + }, { "context": "RunModal", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index fd69b1125ccbb6dc615e06983b5b664e76e8d256..8ce8102297d6664fe8ffa29d115baadf003358a9 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1444,6 +1444,18 @@ "cmd-shift-i": "branch_picker::FilterRemotes", }, }, + { + "context": "ImageViewer", + "use_key_equivalents": true, + "bindings": { + "cmd-=": "image_viewer::ZoomIn", + "cmd-+": "image_viewer::ZoomIn", + "cmd--": "image_viewer::ZoomOut", + "cmd-0": "image_viewer::ResetZoom", + "cmd-1": "image_viewer::ZoomToActualSize", + "cmd-shift-0": "image_viewer::FitToView", + }, + }, { "context": "RunModal", "bindings": { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 4a9de2b7cfcd4b15b6f74c31db073ed14b98c4b8..eed4da1c79c046c65736f649ed79b0740cf67f67 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1363,6 +1363,17 @@ "ctrl-shift-i": "branch_picker::FilterRemotes", }, }, + { + "context": "ImageViewer", + "bindings": { + "ctrl-=": "image_viewer::ZoomIn", + "ctrl-+": "image_viewer::ZoomIn", + "ctrl--": "image_viewer::ZoomOut", + "ctrl-0": "image_viewer::ResetZoom", + "ctrl-1": "image_viewer::ZoomToActualSize", + "ctrl-shift-0": "image_viewer::FitToView", + }, + }, { "context": "RunModal", "bindings": { diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 9374aa71fb678b519cc5fa44ebe125af92f8e0c8..a591af53e5ca654647d5ad914254e21f5829c240 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -7,39 +7,79 @@ use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; use gpui::{ - AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity, - Window, canvas, div, fill, img, opaque_grey, point, size, + AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, + FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, + LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions, + canvas, div, img, opaque_grey, point, px, size, }; use language::File as _; use persistence::IMAGE_VIEWER; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; use theme::{Theme, ThemeSettings}; -use ui::prelude::*; +use ui::{Tooltip, prelude::*}; use util::paths::PathExt; use workspace::{ - ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, + ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, + WorkspaceId, delete_unloaded_items, invalid_item_view::InvalidItemView, - item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, + item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams}, }; pub use crate::image_info::*; pub use crate::image_viewer_settings::*; +actions!( + image_viewer, + [ + /// Zoom in the image. + ZoomIn, + /// Zoom out the image. + ZoomOut, + /// Reset zoom to 100%. + ResetZoom, + /// Fit the image to view. + FitToView, + /// Zoom to actual size (100%). + ZoomToActualSize + ] +); + +const MIN_ZOOM: f32 = 0.1; +const MAX_ZOOM: f32 = 20.0; +const ZOOM_STEP: f32 = 1.1; +const SCROLL_LINE_MULTIPLIER: f32 = 20.0; +const BASE_SQUARE_SIZE: f32 = 48.0; + pub struct ImageView { image_item: Entity, project: Entity, focus_handle: FocusHandle, + zoom_level: f32, + pan_offset: Point, + last_mouse_position: Option>, + container_bounds: Option>, + image_size: Option<(u32, u32)>, } impl ImageView { + fn is_dragging(&self) -> bool { + self.last_mouse_position.is_some() + } + pub fn new( image_item: Entity, project: Entity, window: &mut Window, cx: &mut Context, ) -> Self { + // Start loading the image to render in the background to prevent the view + // from flickering in most cases. + let _ = image_item.update(cx, |image, cx| { + image.image.clone().get_render_image(window, cx) + }); + cx.subscribe(&image_item, Self::on_image_event).detach(); cx.on_release_in(window, |this, window, cx| { let image_data = this.image_item.read(cx).image.clone(); @@ -50,10 +90,20 @@ impl ImageView { }) .detach(); + let image_size = image_item + .read(cx) + .image_metadata + .map(|m| (m.width, m.height)); + Self { image_item, project, focus_handle: cx.focus_handle(), + zoom_level: 1.0, + pan_offset: Point::default(), + last_mouse_position: None, + container_bounds: None, + image_size, } } @@ -67,12 +117,339 @@ impl ImageView { ImageItemEvent::MetadataUpdated | ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => { + self.image_size = self + .image_item + .read(cx) + .image_metadata + .map(|m| (m.width, m.height)); cx.emit(ImageViewEvent::TitleChanged); cx.notify(); } ImageItemEvent::ReloadNeeded => {} } } + + fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context) { + self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx); + } + + fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { + self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx); + } + + fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context) { + self.zoom_level = 1.0; + self.pan_offset = Point::default(); + cx.notify(); + } + + fn fit_to_view(&mut self, _: &FitToView, _window: &mut Window, cx: &mut Context) { + if let Some((bounds, (img_width, img_height))) = self.container_bounds.zip(self.image_size) + { + let container_width: f32 = bounds.size.width.into(); + let container_height: f32 = bounds.size.height.into(); + let scale_x = container_width / img_width as f32; + let scale_y = container_height / img_height as f32; + self.zoom_level = scale_x.min(scale_y).min(1.0); + self.pan_offset = Point::default(); + cx.notify(); + } + } + + fn zoom_to_actual_size( + &mut self, + _: &ZoomToActualSize, + _window: &mut Window, + cx: &mut Context, + ) { + self.zoom_level = 1.0; + self.pan_offset = Point::default(); + cx.notify(); + } + + fn set_zoom( + &mut self, + new_zoom: f32, + zoom_center: Option>, + cx: &mut Context, + ) { + let old_zoom = self.zoom_level; + self.zoom_level = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM); + + if let Some((center, bounds)) = zoom_center.zip(self.container_bounds) { + let relative_center = point( + center.x - bounds.origin.x - bounds.size.width / 2.0, + center.y - bounds.origin.y - bounds.size.height / 2.0, + ); + + let mouse_offset_from_image = relative_center - self.pan_offset; + + let zoom_ratio = self.zoom_level / old_zoom; + + self.pan_offset += mouse_offset_from_image * (1.0 - zoom_ratio); + } + + cx.notify(); + } + + fn handle_scroll_wheel( + &mut self, + event: &ScrollWheelEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if event.modifiers.control || event.modifiers.platform { + let delta: f32 = match event.delta { + ScrollDelta::Pixels(pixels) => pixels.y.into(), + ScrollDelta::Lines(lines) => lines.y * SCROLL_LINE_MULTIPLIER, + }; + let zoom_factor = if delta > 0.0 { + 1.0 + delta.abs() * 0.01 + } else { + 1.0 / (1.0 + delta.abs() * 0.01) + }; + self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); + } else { + let delta = match event.delta { + ScrollDelta::Pixels(pixels) => pixels, + ScrollDelta::Lines(lines) => lines.map(|d| px(d * SCROLL_LINE_MULTIPLIER)), + }; + self.pan_offset += delta; + cx.notify(); + } + } + + fn handle_mouse_down( + &mut self, + event: &MouseDownEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if event.button == MouseButton::Left || event.button == MouseButton::Middle { + self.last_mouse_position = Some(event.position); + cx.notify(); + } + } + + fn handle_mouse_up( + &mut self, + _event: &MouseUpEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.last_mouse_position = None; + cx.notify(); + } + + fn handle_mouse_move( + &mut self, + event: &MouseMoveEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if self.is_dragging() { + if let Some(last_pos) = self.last_mouse_position { + let delta = event.position - last_pos; + self.pan_offset += delta; + } + self.last_mouse_position = Some(event.position); + cx.notify(); + } + } +} + +struct ImageContentElement { + image_view: Entity, +} + +impl ImageContentElement { + fn new(image_view: Entity) -> Self { + Self { image_view } + } +} + +impl IntoElement for ImageContentElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ImageContentElement { + type RequestLayoutState = (); + type PrepaintState = Option<(AnyElement, bool)>; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ( + window.request_layout( + Style { + size: size(relative(1.).into(), relative(1.).into()), + ..Default::default() + }, + [], + cx, + ), + (), + ) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let image_view = self.image_view.read(cx); + let image = image_view.image_item.read(cx).image.clone(); + + let zoom_level = image_view.zoom_level; + let pan_offset = image_view.pan_offset; + let border_color = cx.theme().colors().border; + + let is_dragging = image_view.is_dragging(); + + let scaled_size = image_view + .image_size + .map(|(w, h)| (px(w as f32 * zoom_level), px(h as f32 * zoom_level))); + + let (mut left, mut top) = (px(0.0), px(0.0)); + let mut scaled_width = px(0.0); + let mut scaled_height = px(0.0); + + if let Some((width, height)) = scaled_size { + scaled_width = width; + scaled_height = height; + + let center_x = bounds.size.width / 2.0; + let center_y = bounds.size.height / 2.0; + + left = center_x - (scaled_width / 2.0) + pan_offset.x; + top = center_y - (scaled_height / 2.0) + pan_offset.y; + } + + self.image_view.update(cx, |this, _| { + this.container_bounds = Some(bounds); + }); + + let mut image_content = div() + .relative() + .size_full() + .child( + div() + .absolute() + .left(left) + .top(top) + .w(scaled_width) + .h(scaled_height) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _cx| { + let bounds_x: f32 = bounds.origin.x.into(); + let bounds_y: f32 = bounds.origin.y.into(); + let bounds_width: f32 = bounds.size.width.into(); + let bounds_height: f32 = bounds.size.height.into(); + let square_size = BASE_SQUARE_SIZE * zoom_level; + let cols = (bounds_width / square_size).ceil() as i32 + 1; + let rows = (bounds_height / square_size).ceil() as i32 + 1; + for row in 0..rows { + for col in 0..cols { + if (row + col) % 2 == 0 { + continue; + } + let x = bounds_x + col as f32 * square_size; + let y = bounds_y + row as f32 * square_size; + let w = square_size.min(bounds_x + bounds_width - x); + let h = square_size.min(bounds_y + bounds_height - y); + if w > 0.0 && h > 0.0 { + let rect = Bounds::new( + point(px(x), px(y)), + size(px(w), px(h)), + ); + window.paint_quad(gpui::fill( + rect, + opaque_grey(0.6, 1.0), + )); + } + } + } + let border_rect = Bounds::new( + point(px(bounds_x), px(bounds_y)), + size(px(bounds_width), px(bounds_height)), + ); + window.paint_quad(gpui::outline( + border_rect, + border_color, + gpui::BorderStyle::default(), + )); + }, + ) + .size_full() + .absolute() + .top_0() + .left_0() + .bg(gpui::rgb(0xCCCCCD)), + ) + .child({ + img(image) + .id(("image-viewer-image", self.image_view.entity_id())) + .size_full() + }), + ) + .into_any_element(); + + image_content.prepaint_as_root(bounds.origin, bounds.size.into(), window, cx); + Some((image_content, is_dragging)) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let Some((mut element, is_dragging)) = prepaint.take() else { + return; + }; + + if is_dragging { + let image_view = self.image_view.downgrade(); + window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| { + if phase == DispatchPhase::Bubble + && let Some(entity) = image_view.upgrade() + { + entity.update(cx, |this, cx| { + this.last_mouse_position = None; + cx.notify(); + }); + } + }); + } + + element.paint(window, cx); + } } pub enum ImageViewEvent { @@ -191,6 +568,11 @@ impl Item for ImageView { image_item: self.image_item.clone(), project: self.project.clone(), focus_handle: cx.focus_handle(), + zoom_level: self.zoom_level, + pan_offset: self.pan_offset, + last_mouse_position: None, + container_bounds: None, + image_size: self.image_size, }))) } @@ -303,81 +685,36 @@ impl Focusable for ImageView { } impl Render for ImageView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let image = self.image_item.read(cx).image.clone(); - let checkered_background = - |bounds: Bounds, _, window: &mut Window, _cx: &mut App| { - let square_size: f32 = 32.0; - - let start_y = bounds.origin.y.into(); - let height: f32 = bounds.size.height.into(); - let start_x = bounds.origin.x.into(); - let width: f32 = bounds.size.width.into(); - - let mut y = start_y; - let mut x = start_x; - let mut color_swapper = true; - // draw checkerboard pattern - while y < start_y + height { - // Keeping track of the grid in order to be resilient to resizing - let start_swap = color_swapper; - while x < start_x + width { - // Clamp square dimensions to not exceed bounds - let square_width = square_size.min(start_x + width - x); - let square_height = square_size.min(start_y + height - y); - - let rect = Bounds::new( - point(px(x), px(y)), - size(px(square_width), px(square_height)), - ); - - let color = if color_swapper { - opaque_grey(0.6, 0.4) - } else { - opaque_grey(0.7, 0.4) - }; - - window.paint_quad(fill(rect, color)); - color_swapper = !color_swapper; - x += square_size; - } - x = start_x; - color_swapper = !start_swap; - y += square_size; - } - }; - - div().track_focus(&self.focus_handle(cx)).size_full().child( - div() - .flex() - .justify_center() - .items_center() - .w_full() - // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full - .h_full() - .child( - div() - .relative() - .max_w_full() - .max_h_full() - .child( - canvas(|_, _, _| (), checkered_background) - .border_2() - .border_color(cx.theme().styles.colors.border) - .size_full() - .absolute() - .top_0() - .left_0(), - ) - .child( - img(image) - .object_fit(ObjectFit::ScaleDown) - .max_w_full() - .max_h_full() - .id("img"), - ), - ), - ) + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .track_focus(&self.focus_handle(cx)) + .key_context("ImageViewer") + .on_action(cx.listener(Self::zoom_in)) + .on_action(cx.listener(Self::zoom_out)) + .on_action(cx.listener(Self::reset_zoom)) + .on_action(cx.listener(Self::fit_to_view)) + .on_action(cx.listener(Self::zoom_to_actual_size)) + .size_full() + .relative() + .bg(cx.theme().colors().editor_background) + .child( + div() + .id("image-container") + .size_full() + .overflow_hidden() + .cursor(if self.is_dragging() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) + .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) + .on_mouse_move(cx.listener(Self::handle_mouse_move)) + .child(ImageContentElement::new(cx.entity())), + ) } } @@ -411,6 +748,120 @@ impl ProjectItem for ImageView { } } +pub struct ImageViewToolbarControls { + image_view: Option>, + _subscription: Option, +} + +impl ImageViewToolbarControls { + pub fn new() -> Self { + Self { + image_view: None, + _subscription: None, + } + } +} + +impl Render for ImageViewToolbarControls { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) else { + return div().into_any_element(); + }; + + let zoom_level = image_view.read(cx).zoom_level; + let zoom_percentage = format!("{}%", (zoom_level * 100.0).round() as i32); + + h_flex() + .gap_1() + .child( + IconButton::new("zoom-out", IconName::Dash) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| Tooltip::for_action("Zoom Out", &ZoomOut, cx)) + .on_click({ + let image_view = image_view.downgrade(); + move |_, window, cx| { + if let Some(view) = image_view.upgrade() { + view.update(cx, |this, cx| { + this.zoom_out(&ZoomOut, window, cx); + }); + } + } + }), + ) + .child( + Button::new("zoom-level", zoom_percentage) + .label_size(LabelSize::Small) + .tooltip(|_window, cx| Tooltip::for_action("Reset Zoom", &ResetZoom, cx)) + .on_click({ + let image_view = image_view.downgrade(); + move |_, window, cx| { + if let Some(view) = image_view.upgrade() { + view.update(cx, |this, cx| { + this.reset_zoom(&ResetZoom, window, cx); + }); + } + } + }), + ) + .child( + IconButton::new("zoom-in", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| Tooltip::for_action("Zoom In", &ZoomIn, cx)) + .on_click({ + let image_view = image_view.downgrade(); + move |_, window, cx| { + if let Some(view) = image_view.upgrade() { + view.update(cx, |this, cx| { + this.zoom_in(&ZoomIn, window, cx); + }); + } + } + }), + ) + .child( + IconButton::new("fit-to-view", IconName::Maximize) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| Tooltip::for_action("Fit to View", &FitToView, cx)) + .on_click({ + let image_view = image_view.downgrade(); + move |_, window, cx| { + if let Some(view) = image_view.upgrade() { + view.update(cx, |this, cx| { + this.fit_to_view(&FitToView, window, cx); + }); + } + } + }), + ) + .into_any_element() + } +} + +impl EventEmitter for ImageViewToolbarControls {} + +impl ToolbarItemView for ImageViewToolbarControls { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.image_view = None; + self._subscription = None; + + if let Some(item) = active_pane_item.and_then(|i| i.downcast::()) { + self._subscription = Some(cx.observe(&item, |_, _, cx| { + cx.notify(); + })); + self.image_view = Some(item.downgrade()); + cx.notify(); + return ToolbarItemLocation::PrimaryRight; + } + + ToolbarItemLocation::Hidden + } +} + pub fn init(cx: &mut App) { workspace::register_project_item::(cx); workspace::register_serializable_item::(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1fbbc1a808365e1de6a06de475d68e6037f59dab..4b69a45d76bcb57a2616589c4068445bb6e81550 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1241,6 +1241,8 @@ fn initialize_pane( toolbar.add_item(agent_diff_toolbar, window, cx); let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx)); toolbar.add_item(basedpyright_banner, window, cx); + let image_view_toolbar = cx.new(|_| image_viewer::ImageViewToolbarControls::new()); + toolbar.add_item(image_view_toolbar, window, cx); }) }); } @@ -4794,6 +4796,7 @@ mod tests { "git_picker", "go_to_line", "icon_theme_selector", + "image_viewer", "inline_assistant", "journal", "keymap_editor",