Files
oak-gpui/crates/image_viewer/src/image_viewer.rs
T
Lukas Wirth fd3ca0303f workspace: Handle non-cloneable items better (#41215)
When trying to split and clone a non clone-able workspace item we now
attempt split and move instead of doing nothing. Additionally we disable
the split menu buttons if we can't split the active item at all.

Release Notes:

- Improved handling of unsplittable panes
2025-10-26 13:24:26 +00:00

472 lines
14 KiB
Rust

mod image_info;
mod image_viewer_settings;
use std::path::Path;
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,
};
use language::{DiskState, 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 util::paths::PathExt;
use workspace::{
ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
invalid_item_view::InvalidItemView,
item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
};
pub use crate::image_info::*;
pub use crate::image_viewer_settings::*;
pub struct ImageView {
image_item: Entity<ImageItem>,
project: Entity<Project>,
focus_handle: FocusHandle,
}
impl ImageView {
pub fn new(
image_item: Entity<ImageItem>,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
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();
if let Some(image) = image_data.clone().get_render_image(window, cx) {
cx.drop_image(image, None);
}
image_data.remove_asset(cx);
})
.detach();
Self {
image_item,
project,
focus_handle: cx.focus_handle(),
}
}
fn on_image_event(
&mut self,
_: Entity<ImageItem>,
event: &ImageItemEvent,
cx: &mut Context<Self>,
) {
match event {
ImageItemEvent::MetadataUpdated
| ImageItemEvent::FileHandleChanged
| ImageItemEvent::Reloaded => {
cx.emit(ImageViewEvent::TitleChanged);
cx.notify();
}
ImageItemEvent::ReloadNeeded => {}
}
}
}
pub enum ImageViewEvent {
TitleChanged,
}
impl EventEmitter<ImageViewEvent> for ImageView {}
impl Item for ImageView {
type Event = ImageViewEvent;
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
match event {
ImageViewEvent::TitleChanged => {
f(workspace::item::ItemEvent::UpdateTab);
f(workspace::item::ItemEvent::UpdateBreadcrumbs);
}
}
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
f(self.image_item.entity_id(), self.image_item.read(cx))
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
let abs_path = self.image_item.read(cx).abs_path(cx)?;
let file_path = abs_path.compact().to_string_lossy().into_owned();
Some(file_path.into())
}
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
let project_path = self.image_item.read(cx).project_path(cx);
let label_color = if ItemSettings::get_global(cx).git_status {
let git_status = self
.project
.read(cx)
.project_path_git_status(&project_path, cx)
.map(|status| status.summary())
.unwrap_or_default();
self.project
.read(cx)
.entry_for_path(&project_path, cx)
.map(|entry| {
entry_git_aware_label_color(git_status, entry.is_ignored, params.selected)
})
.unwrap_or_else(|| params.text_color())
} else {
params.text_color()
};
Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
.single_line()
.color(label_color)
.when(params.preview, |this| this.italic())
.into_any_element()
}
fn tab_content_text(&self, _: usize, cx: &App) -> SharedString {
self.image_item
.read(cx)
.file
.file_name(cx)
.to_string()
.into()
}
fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
let path = self.image_item.read(cx).abs_path(cx)?;
ItemSettings::get_global(cx)
.file_icons
.then(|| FileIcons::get_icon(&path, cx))
.flatten()
.map(Icon::from_path)
}
fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
let show_breadcrumb = EditorSettings::get_global(cx).toolbar.breadcrumbs;
if show_breadcrumb {
ToolbarItemLocation::PrimaryLeft
} else {
ToolbarItemLocation::Hidden
}
}
fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
let settings = ThemeSettings::get_global(cx);
Some(vec![BreadcrumbText {
text,
highlights: None,
font: Some(settings.buffer_font.clone()),
}])
}
fn can_split(&self) -> bool {
true
}
fn clone_on_split(
&self,
_workspace_id: Option<WorkspaceId>,
_: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Self>>>
where
Self: Sized,
{
Task::ready(Some(cx.new(|cx| Self {
image_item: self.image_item.clone(),
project: self.project.clone(),
focus_handle: cx.focus_handle(),
})))
}
fn has_deleted_file(&self, cx: &App) -> bool {
self.image_item.read(cx).file.disk_state() == DiskState::Deleted
}
fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
workspace::item::ItemBufferKind::Singleton
}
}
fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
let mut path = image.file.path().clone();
if project.visible_worktrees(cx).count() > 1
&& let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx)
{
path = worktree.read(cx).root_name().join(&path);
}
path.display(project.path_style(cx)).to_string()
}
impl SerializableItem for ImageView {
fn serialized_item_kind() -> &'static str {
"ImageView"
}
fn deserialize(
project: Entity<Project>,
_workspace: WeakEntity<Workspace>,
workspace_id: WorkspaceId,
item_id: ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<anyhow::Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
let image_path = IMAGE_VIEWER
.get_image_path(item_id, workspace_id)?
.context("No image path found")?;
let (worktree, relative_path) = project
.update(cx, |project, cx| {
project.find_or_create_worktree(image_path.clone(), false, cx)
})?
.await
.context("Path not found")?;
let worktree_id = worktree.update(cx, |worktree, _cx| worktree.id())?;
let project_path = ProjectPath {
worktree_id,
path: relative_path,
};
let image_item = project
.update(cx, |project, cx| project.open_image(project_path, cx))?
.await?;
cx.update(
|window, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, window, cx))),
)?
})
}
fn cleanup(
workspace_id: WorkspaceId,
alive_items: Vec<ItemId>,
_window: &mut Window,
cx: &mut App,
) -> Task<anyhow::Result<()>> {
delete_unloaded_items(
alive_items,
workspace_id,
"image_viewers",
&IMAGE_VIEWER,
cx,
)
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<anyhow::Result<()>>> {
let workspace_id = workspace.database_id()?;
let image_path = self.image_item.read(cx).abs_path(cx)?;
Some(cx.background_spawn({
async move {
log::debug!("Saving image at path {image_path:?}");
IMAGE_VIEWER
.save_image_path(item_id, workspace_id, image_path)
.await
}
}))
}
fn should_serialize(&self, _event: &Self::Event) -> bool {
false
}
}
impl EventEmitter<()> for ImageView {}
impl Focusable for ImageView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ImageView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let image = self.image_item.read(cx).image.clone();
let checkered_background =
|bounds: Bounds<Pixels>, _, 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"),
),
),
)
}
}
impl ProjectItem for ImageView {
type Item = ImageItem;
fn for_project_item(
project: Entity<Project>,
_: Option<&Pane>,
item: Entity<Self::Item>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self
where
Self: Sized,
{
Self::new(item, project, window, cx)
}
fn for_broken_project_item(
abs_path: &Path,
is_local: bool,
e: &anyhow::Error,
window: &mut Window,
cx: &mut App,
) -> Option<InvalidItemView>
where
Self: Sized,
{
Some(InvalidItemView::new(abs_path, is_local, e, window, cx))
}
}
pub fn init(cx: &mut App) {
ImageViewerSettings::register(cx);
workspace::register_project_item::<ImageView>(cx);
workspace::register_serializable_item::<ImageView>(cx);
}
mod persistence {
use std::path::PathBuf;
use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
pub struct ImageViewerDb(ThreadSafeConnection);
impl Domain for ImageViewerDb {
const NAME: &str = stringify!(ImageViewerDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE image_viewers (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
image_path BLOB,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)];
}
db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]);
impl ImageViewerDb {
query! {
pub async fn save_image_path(
item_id: ItemId,
workspace_id: WorkspaceId,
image_path: PathBuf
) -> Result<()> {
INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path)
VALUES (?, ?, ?)
}
}
query! {
pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
SELECT image_path
FROM image_viewers
WHERE item_id = ? AND workspace_id = ?
}
}
}
}