ZIm/crates/image_viewer/src/image_viewer.rs
Nathan Sobo 6fca1d2b0b
Eliminate GPUI View, ViewContext, and WindowContext types (#22632)
There's still a bit more work to do on this, but this PR is compiling
(with warnings) after eliminating the key types. When the tasks below
are complete, this will be the new narrative for GPUI:

- `Entity<T>` - This replaces `View<T>`/`Model<T>`. It represents a unit
of state, and if `T` implements `Render`, then `Entity<T>` implements
`Element`.
- `&mut App` This replaces `AppContext` and represents the app.
- `&mut Context<T>` This replaces `ModelContext` and derefs to `App`. It
is provided by the framework when updating an entity.
- `&mut Window` Broken out of `&mut WindowContext` which no longer
exists. Every method that once took `&mut WindowContext` now takes `&mut
Window, &mut App` and every method that took `&mut ViewContext<T>` now
takes `&mut Window, &mut Context<T>`

Not pictured here are the two other failed attempts. It's been quite a
month!

Tasks:

- [x] Remove `View`, `ViewContext`, `WindowContext` and thread through
`Window`
- [x] [@cole-miller @mikayla-maki] Redraw window when entities change
- [x] [@cole-miller @mikayla-maki] Get examples and Zed running
- [x] [@cole-miller @mikayla-maki] Fix Zed rendering
- [x] [@mikayla-maki] Fix todo! macros and comments
- [x] Fix a bug where the editor would not be redrawn because of view
caching
- [x] remove publicness window.notify() and replace with
`AppContext::notify`
- [x] remove `observe_new_window_models`, replace with
`observe_new_models` with an optional window
- [x] Fix a bug where the project panel would not be redrawn because of
the wrong refresh() call being used
- [x] Fix the tests
- [x] Fix warnings by eliminating `Window` params or using `_`
- [x] Fix conflicts
- [x] Simplify generic code where possible
- [x] Rename types
- [ ] Update docs

### issues post merge

- [x] Issues switching between normal and insert mode
- [x] Assistant re-rendering failure
- [x] Vim test failures
- [x] Mac build issue



Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Joseph <joseph@zed.dev>
Co-authored-by: max <max@zed.dev>
Co-authored-by: Michael Sloan <michael@zed.dev>
Co-authored-by: Mikayla Maki <mikaylamaki@Mikaylas-MacBook-Pro.local>
Co-authored-by: Mikayla <mikayla.c.maki@gmail.com>
Co-authored-by: joão <joao@zed.dev>
2025-01-26 03:02:45 +00:00

443 lines
13 KiB
Rust

use std::path::PathBuf;
use anyhow::Context as _;
use editor::items::entry_git_aware_label_color;
use file_icons::FileIcons;
use gpui::{
canvas, div, fill, img, opaque_grey, point, size, AnyElement, App, Bounds, Context, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ObjectFit,
ParentElement, Render, Styled, Task, WeakEntity, Window,
};
use persistence::IMAGE_VIEWER;
use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath};
use settings::Settings;
use theme::Theme;
use ui::prelude::*;
use util::paths::PathExt;
use workspace::{
item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
ItemId, ItemSettings, ToolbarItemLocation, Workspace, WorkspaceId,
};
const IMAGE_VIEWER_KIND: &str = "ImageView";
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>,
cx: &mut Context<Self>,
) -> Self {
cx.subscribe(&image_item, Self::on_image_event).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::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 is_singleton(&self, _cx: &App) -> bool {
true
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
let abs_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
let file_path = abs_path.compact().to_string_lossy().to_string();
Some(file_path.into())
}
fn tab_content(&self, params: TabContentParams, _: &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()
};
let title = self
.image_item
.read(cx)
.file
.file_name(cx)
.to_string_lossy()
.to_string();
Label::new(title)
.single_line()
.color(label_color)
.italic(params.preview)
.into_any_element()
}
fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
let path = self.image_item.read(cx).path();
ItemSettings::get_global(cx)
.file_icons
.then(|| FileIcons::get_icon(path, cx))
.flatten()
.map(Icon::from_path)
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
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);
Some(vec![BreadcrumbText {
text,
highlights: None,
font: None,
}])
}
fn clone_on_split(
&self,
_workspace_id: Option<WorkspaceId>,
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<Self>>
where
Self: Sized,
{
Some(cx.new(|cx| Self {
image_item: self.image_item.clone(),
project: self.project.clone(),
focus_handle: cx.focus_handle(),
}))
}
}
fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
let path = image.file.file_name(cx);
if project.visible_worktrees(cx).count() <= 1 {
return path.to_string_lossy().to_string();
}
project
.worktree_for_id(image.project_path(cx).worktree_id, cx)
.map(|worktree| {
PathBuf::from(worktree.read(cx).root_name())
.join(path)
.to_string_lossy()
.to_string()
})
.unwrap_or_else(|| path.to_string_lossy().to_string())
}
impl SerializableItem for ImageView {
fn serialized_item_kind() -> &'static str {
IMAGE_VIEWER_KIND
}
fn deserialize(
project: Entity<Project>,
_workspace: WeakEntity<Workspace>,
workspace_id: WorkspaceId,
item_id: ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<Entity<Self>>> {
window.spawn(cx, |mut cx| async move {
let image_path = IMAGE_VIEWER
.get_image_path(item_id, workspace_id)?
.ok_or_else(|| anyhow::anyhow!("No image path found"))?;
let (worktree, relative_path) = project
.update(&mut cx, |project, cx| {
project.find_or_create_worktree(image_path.clone(), false, cx)
})?
.await
.context("Path not found")?;
let worktree_id = worktree.update(&mut cx, |worktree, _cx| worktree.id())?;
let project_path = ProjectPath {
worktree_id,
path: relative_path.into(),
};
let image_item = project
.update(&mut cx, |project, cx| project.open_image(project_path, cx))?
.await?;
cx.update(|_, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, cx))))?
})
}
fn cleanup(
workspace_id: WorkspaceId,
alive_items: Vec<ItemId>,
window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<()>> {
window.spawn(cx, |_| {
IMAGE_VIEWER.delete_unloaded_items(workspace_id, alive_items)
})
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
let image_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
Some(cx.background_executor().spawn({
async move {
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 = 32.0;
let start_y = bounds.origin.y.0;
let height = bounds.size.height.0;
let start_x = bounds.origin.x.0;
let width = bounds.size.width.0;
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 {
let rect =
Bounds::new(point(px(x), px(y)), size(px(square_size), px(square_size)));
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;
}
};
let checkered_background = canvas(|_, _, _| (), checkered_background)
.border_2()
.border_color(cx.theme().styles.colors.border)
.size_full()
.absolute()
.top_0()
.left_0();
div()
.track_focus(&self.focus_handle(cx))
.size_full()
.child(checkered_background)
.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(
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>,
item: Entity<Self::Item>,
_: &mut Window,
cx: &mut Context<Self>,
) -> Self
where
Self: Sized,
{
Self::new(item, project, cx)
}
}
pub fn init(cx: &mut App) {
workspace::register_project_item::<ImageView>(cx);
workspace::register_serializable_item::<ImageView>(cx)
}
mod persistence {
use anyhow::Result;
use std::path::PathBuf;
use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
define_connection! {
pub static ref IMAGE_VIEWER: ImageViewerDb<WorkspaceDb> =
&[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;
)];
}
impl ImageViewerDb {
query! {
pub async fn update_workspace_id(
new_id: WorkspaceId,
old_id: WorkspaceId,
item_id: ItemId
) -> Result<()> {
UPDATE image_viewers
SET workspace_id = ?
WHERE workspace_id = ? AND item_id = ?
}
}
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 = ?
}
}
pub async fn delete_unloaded_items(
&self,
workspace: WorkspaceId,
alive_items: Vec<ItemId>,
) -> Result<()> {
let placeholders = alive_items
.iter()
.map(|_| "?")
.collect::<Vec<&str>>()
.join(", ");
let query = format!("DELETE FROM image_viewers WHERE workspace_id = ? AND item_id NOT IN ({placeholders})");
self.write(move |conn| {
let mut statement = Statement::prepare(conn, query)?;
let mut next_index = statement.bind(&workspace, 1)?;
for id in alive_items {
next_index = statement.bind(&id, next_index)?;
}
statement.exec()
})
.await
}
}
}