From cea402ac3cc06687b85c5208a625ba904a22883e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 25 Sep 2026 23:21:38 +0800 Subject: [PATCH 1/3] docs: explain HTTP caching for remote images Add a "Cache remote images over HTTP" section to the Image page (en and zh-CN). It explains why GPUI's in-memory image cache does not avoid network requests, the rules for wrapping the application's HttpClient with an HTTP cache, and a compact in-memory example built on http-cache-semantics. Co-Authored-By: Claude Opus 5.5 (1M context) --- website/component/image.md | 227 ++++++++++++++++++++++++++++++- website/zh-CN/component/image.md | 227 ++++++++++++++++++++++++++++++- 2 files changed, 452 insertions(+), 2 deletions(-) diff --git a/website/component/image.md b/website/component/image.md index 775edece2b..110f19e5be 100644 --- a/website/component/image.md +++ b/website/component/image.md @@ -244,6 +244,231 @@ A quick load may finish before the loading view appears. A missing asset key, in `with_loading` and `with_fallback` supply replacement elements; they do not expose a separate `ImageState` enum or automatically add a retry command. If a remote image is essential to the task, provide a nearby retry action in your application's state and rebuild the image with an updated source when the user retries. Avoid showing a fake loading skeleton after the source has already failed. +## Cache remote images over HTTP + +GPUI loads a URL source such as `img("https://...")`, and a remote image in a `TextView` document, through the `HttpClient` installed on the `App`. On native platforms the application chooses that client: the default one fails every request, so install one with `cx.set_http_client(...)` or `Application::with_http_client(...)` before remote images can load. On the web, `gpui_kit::application()` installs a client backed by the browser's Fetch API, and the browser's HTTP cache already applies; this section is about native applications. + +GPUI's image cache sits above that client. It keeps decoded images in memory, keyed by source, which is enough for repeated sources in a running view but not for network traffic: + +- It ends with the process, so every launch downloads every image again. +- It ignores `Cache-Control`, `ETag`, and `Last-Modified`. It cannot keep a response the server allows to be reused, or ask the server whether an older copy is still current. +- It lives only as long as the cache that holds it. An image with its own `.image_cache(...)`, or a loader that keeps a cache per view, requests the image again each time a new view is created. + +To reuse responses across views and launches, wrap the application's `HttpClient` in a client that applies HTTP caching rules, and install the wrapper once at startup. Every remote image then benefits, including images loaded by code the application does not own. Follow these rules: + +- **Cache only a GET without a request body.** Pass every other request through unchanged. +- **Treat the cache as shared.** One client serves the whole application: its own views, extensions, and document views. Do not store a `no-store` or `private` response, or a response to a request carrying `Authorization`, unless the server explicitly allows a shared cache to keep it. A layer below the cache that adds cookies or tokens hides them from the cache, so add credentials above the cache, or leave those hosts uncached. +- **Revalidate instead of downloading again.** Serve a fresh response without the network. When it is stale, send `If-None-Match` or `If-Modified-Since`; on `304 Not Modified`, return the stored body with the refreshed headers. +- **Honor the caller's redirect policy.** `img()` follows redirects, but a loader that authorizes each hop itself requests `RedirectPolicy::NoFollow` and must receive the `3xx` response. Keep the policy in the cache key so a followed result never answers that caller, and cache the redirect response itself by the same rules. +- **Bound memory and disk use.** Cap each entry and the total size, evict old entries, and stream a response that is too large to keep straight through. +- **Authorize before the request.** The cache answers any caller that asks for the same URL. A check that decides whether a caller may reach a URL, such as the network grants of gpui-shell scripts, must run before `send`. The cache then never widens what a caller can reach; it only avoids repeating a request that was already allowed. + +The example below applies these rules in memory. The [http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate implements the HTTP caching rules: freshness, validators, `Vary`, and the restrictions on a shared cache. Add `http-cache-semantics = "2"`, `futures`, `bytes`, and `anyhow` to `Cargo.toml`. + +```rust +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use bytes::Bytes; +use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; +use gpui_kit::http_client::{ + AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, + http::{HeaderValue, response}, +}; +use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; + +/// An [`HttpClient`] that answers GET requests from memory when HTTP caching +/// rules allow it, and forwards everything else to `inner`. +pub struct CachingHttpClient { + inner: Arc, + store: Arc>, +} + +impl CachingHttpClient { + pub fn new(inner: Arc, max_bytes: usize) -> Self { + let store = Store { + max_bytes, + ..Default::default() + }; + Self { + inner, + store: Arc::new(Mutex::new(store)), + } + } +} + +/// The URI plus the caller's redirect policy. A caller that disables +/// redirects must receive the 3xx itself, never a followed result. +type Key = (String, Option); + +struct Entry { + policy: CachePolicy, + body: Bytes, +} + +#[derive(Default)] +struct Store { + entries: HashMap, + /// Keys in insertion order; the oldest is evicted first. + order: VecDeque, + bytes: usize, + max_bytes: usize, +} + +impl Store { + /// A single response may use at most an eighth of the budget. + fn max_entry_bytes(&self) -> usize { + self.max_bytes / 8 + } + + fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { + let entry = self.entries.get(key)?; + Some((entry.policy.clone(), entry.body.clone())) + } + + fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { + self.remove(&key); + self.bytes += body.len(); + self.order.push_back(key.clone()); + self.entries.insert(key, Entry { policy, body }); + while self.bytes > self.max_bytes { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(entry) = self.entries.remove(&oldest) { + self.bytes -= entry.body.len(); + } + } + } + + fn remove(&mut self, key: &Key) { + if let Some(entry) = self.entries.remove(key) { + self.bytes -= entry.body.len(); + self.order.retain(|k| k != key); + } + } +} + +fn respond(head: response::Parts, body: Bytes) -> Response { + Response::from_parts(head, AsyncBody::from_bytes(body)) +} + +impl HttpClient for CachingHttpClient { + fn user_agent(&self) -> Option<&HeaderValue> { + self.inner.user_agent() + } + + fn proxy(&self) -> Option<&Url> { + self.inner.proxy() + } + + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + // Only a GET without a body is cacheable; pass everything else through. + if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { + return self.inner.send(req); + } + + let inner = self.inner.clone(); + let store = self.store.clone(); + async move { + let (request, _) = req.into_parts(); + let redirects = request.extensions.get::().cloned(); + let key = (request.uri.to_string(), redirects); + let cached = store.lock().unwrap().get(&key); + + // Fresh: answer without the network. Stale: send the conditional + // headers (If-None-Match / If-Modified-Since) the policy computed, + // keeping the caller's extensions such as its redirect policy. + let mut outgoing = request.clone(); + if let Some((policy, body)) = &cached { + match policy.before_request(&request, SystemTime::now()) { + BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), + BeforeRequest::Stale { + request: revalidation, + .. + } => { + outgoing.headers = revalidation.headers; + } + } + } + + let (head, body) = inner + .send(Request::from_parts(outgoing, AsyncBody::empty())) + .await? + .into_parts(); + + // 304: keep the cached body under the refreshed headers. + if let Some((policy, cached_body)) = cached { + match policy.after_response(&request, &head, SystemTime::now()) { + AfterResponse::NotModified(policy, head) => { + let mut store = store.lock().unwrap(); + store.insert(key, policy, cached_body.clone()); + return Ok(respond(head, cached_body)); + } + AfterResponse::Modified(..) => {} + } + } + + // `CachePolicy::new` evaluates the response as a shared cache: + // `no-store` and `private` responses, and most responses to requests + // carrying `Authorization`, are not storable. + let policy = CachePolicy::new(&request, &head); + if !policy.is_storable() { + store.lock().unwrap().remove(&key); + return Ok(Response::from_parts(head, body)); + } + + let limit = store.lock().unwrap().max_entry_bytes(); + let mut bytes = Vec::new(); + let mut reader = body.take(limit as u64 + 1); + reader.read_to_end(&mut bytes).await?; + let body = reader.into_inner(); + if bytes.len() > limit { + // Too large to keep: return what was read, then the rest of the stream. + store.lock().unwrap().remove(&key); + let rest = Cursor::new(bytes).chain(body); + return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); + } + + let bytes = Bytes::from(bytes); + store.lock().unwrap().insert(key, policy, bytes.clone()); + Ok(respond(head, bytes)) + } + .boxed() + } +} +``` + +Install the wrapper around the client the application already uses. Here `reqwest_client` is the `gpui-pre-reqwest-client` crate at the GPUI snapshot version your `gpui-kit` release pins: + +```rust +use std::sync::Arc; + +gpui_kit::application().run(|cx| { + gpui_kit::init(cx); + + let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") + .expect("failed to create the HTTP client"); + let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); + cx.set_http_client(Arc::new(cached)); + + // Open windows here. +}); +``` + +The example keeps its scope small. Extend it where your application needs more: + +- **Persistence.** Entries disappear when the application quits. To keep images across launches, store each body with its `CachePolicy` on disk (`CachePolicy` implements `serde` traits), write files atomically, and remove entries past a size or age limit at startup. A client built on `reqwest` can instead use caching middleware with a disk store, such as [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest). +- **Variants.** Each URL keeps one response. A response that differs by `Vary` replaces the previous variant. +- **Duplicate requests.** Concurrent misses for the same URL each reach the network. GPUI's image loader already shares one load per source. +- **Eviction.** The oldest entry is removed first, whether or not it was used recently. Use an LRU structure when access patterns matter. + ## SVG as an image or an icon Both forms can load a key from the same `AssetSource`, but they render differently: @@ -288,7 +513,7 @@ Applying `.text_color(...)` to `img("images/brand.svg")` does **not** recolor th - Supply image dimensions close to the displayed size. A tiny thumbnail does not need the same encoded pixels as a full-screen photo. - Compress source files and verify their appearance on your target displays. Use a suitable format for photos versus line art; do not assume every platform or decoder accepts every format. - Keep a stable ratio or fixed bounds for images that arrive later. The loading and failure views should occupy the same region. -- GPUI's default image cache already handles ordinary repeated sources. Introduce a custom cache only for a measured lifetime or memory requirement. +- GPUI's default image cache already handles ordinary repeated sources. Introduce a custom cache only for a measured lifetime or memory requirement. To avoid downloading remote images again in new views or after a restart, cache at the HTTP layer instead; see [Cache remote images over HTTP](#cache-remote-images-over-http). - For a large scrolling gallery, create only the visible or nearby items using the collection APIs. The `img()` call alone is not a lazy-loading policy. - Pair informative images with visible descriptive text or an accessible description in the surrounding UI. Decorative images need no duplicated narration. Make image-driven commands real controls with names, focus, and keyboard activation. diff --git a/website/zh-CN/component/image.md b/website/zh-CN/component/image.md index e8e030c8f9..2018f00871 100644 --- a/website/zh-CN/component/image.md +++ b/website/zh-CN/component/image.md @@ -244,6 +244,231 @@ img("images/cover.png") `with_loading` 和 `with_fallback` 只负责提供替代元素;它们不会暴露独立的 `ImageState` 枚举,也不会自动添加重试命令。如果远程图片是任务必需内容,应在应用状态中提供相邻的重试操作,并在用户重试时使用更新后的来源重新构建图片。来源已经失败时,不要继续显示假装正在加载的骨架屏。 +## 远程图片的 HTTP 缓存 + +`img("https://...")` 这类 URL 来源,以及 `TextView` 文档中的远程图片,都由 GPUI 通过安装在 `App` 上的 `HttpClient` 加载。在原生平台上,这个客户端由应用决定:默认客户端会让所有请求失败,因此要先用 `cx.set_http_client(...)` 或 `Application::with_http_client(...)` 安装一个客户端,远程图片才能加载。在 Web 上,`gpui_kit::application()` 会安装基于浏览器 Fetch API 的客户端,浏览器自身的 HTTP 缓存已经生效;本节只讨论原生应用。 + +GPUI 的图片缓存位于这个客户端之上。它在内存中按来源保存解码后的图片,足以应付运行中视图里的重复来源,却无法减少网络请求: + +- 进程退出后缓存随之消失,每次启动都要重新下载所有图片。 +- 它不理会 `Cache-Control`、`ETag` 和 `Last-Modified`,既不能保留服务器允许复用的响应,也不能向服务器确认旧副本是否仍然有效。 +- 它的寿命取决于持有它的缓存。使用独立 `.image_cache(...)` 的图片,或按视图分别缓存的加载器,每创建一个新视图都会重新请求图片。 + +要跨视图、跨启动复用响应,可以用一个遵循 HTTP 缓存规则的客户端包装应用原有的 `HttpClient`,并在启动时安装一次。此后所有远程图片都会受益,包括不由应用自己编写的代码加载的图片。实现时遵循以下规则: + +- **只缓存不带请求体的 GET。** 其他请求原样转发。 +- **按共享缓存处理。** 整个应用共用一个客户端:应用自己的视图、扩展和文档视图都经过它。`no-store` 或 `private` 响应,以及对带 `Authorization` 请求的响应,除非服务器明确允许共享缓存保存,否则都不能存储。如果缓存下层会附加 Cookie 或令牌,缓存看不到这些凭据,因此应在缓存之上添加凭据,或者不缓存这些主机。 +- **用重新验证代替重新下载。** 新鲜的响应直接返回,不访问网络。过期后发送 `If-None-Match` 或 `If-Modified-Since`;收到 `304 Not Modified` 时,用更新后的响应头返回已存储的响应体。 +- **遵守调用方的重定向策略。** `img()` 会跟随重定向,但自行授权每一跳的加载器会请求 `RedirectPolicy::NoFollow`,并且必须拿到 `3xx` 响应。把策略放进缓存键,跟随重定向得到的结果就永远不会回应这类调用方;重定向响应本身也按同样的规则缓存。 +- **限制内存和磁盘用量。** 同时限制单个条目和总大小,淘汰旧条目;过大而不宜保存的响应直接以流的形式转发。 +- **先授权,再请求。** 同一个 URL,缓存会回应任何请求它的调用方。判断调用方能否访问某个 URL 的检查,例如 gpui-shell 脚本的网络授权,必须在 `send` 之前完成。这样缓存不会扩大调用方的访问范围,只是省去重复一次已经获准的请求。 + +下面的示例在内存中实现这些规则。[http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate 负责 HTTP 缓存规则的判断:新鲜度、验证器、`Vary` 以及共享缓存的限制。在 `Cargo.toml` 中加入 `http-cache-semantics = "2"`、`futures`、`bytes` 和 `anyhow`。 + +```rust +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use bytes::Bytes; +use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; +use gpui_kit::http_client::{ + AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, + http::{HeaderValue, response}, +}; +use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; + +/// An [`HttpClient`] that answers GET requests from memory when HTTP caching +/// rules allow it, and forwards everything else to `inner`. +pub struct CachingHttpClient { + inner: Arc, + store: Arc>, +} + +impl CachingHttpClient { + pub fn new(inner: Arc, max_bytes: usize) -> Self { + let store = Store { + max_bytes, + ..Default::default() + }; + Self { + inner, + store: Arc::new(Mutex::new(store)), + } + } +} + +/// The URI plus the caller's redirect policy. A caller that disables +/// redirects must receive the 3xx itself, never a followed result. +type Key = (String, Option); + +struct Entry { + policy: CachePolicy, + body: Bytes, +} + +#[derive(Default)] +struct Store { + entries: HashMap, + /// Keys in insertion order; the oldest is evicted first. + order: VecDeque, + bytes: usize, + max_bytes: usize, +} + +impl Store { + /// A single response may use at most an eighth of the budget. + fn max_entry_bytes(&self) -> usize { + self.max_bytes / 8 + } + + fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { + let entry = self.entries.get(key)?; + Some((entry.policy.clone(), entry.body.clone())) + } + + fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { + self.remove(&key); + self.bytes += body.len(); + self.order.push_back(key.clone()); + self.entries.insert(key, Entry { policy, body }); + while self.bytes > self.max_bytes { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(entry) = self.entries.remove(&oldest) { + self.bytes -= entry.body.len(); + } + } + } + + fn remove(&mut self, key: &Key) { + if let Some(entry) = self.entries.remove(key) { + self.bytes -= entry.body.len(); + self.order.retain(|k| k != key); + } + } +} + +fn respond(head: response::Parts, body: Bytes) -> Response { + Response::from_parts(head, AsyncBody::from_bytes(body)) +} + +impl HttpClient for CachingHttpClient { + fn user_agent(&self) -> Option<&HeaderValue> { + self.inner.user_agent() + } + + fn proxy(&self) -> Option<&Url> { + self.inner.proxy() + } + + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + // Only a GET without a body is cacheable; pass everything else through. + if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { + return self.inner.send(req); + } + + let inner = self.inner.clone(); + let store = self.store.clone(); + async move { + let (request, _) = req.into_parts(); + let redirects = request.extensions.get::().cloned(); + let key = (request.uri.to_string(), redirects); + let cached = store.lock().unwrap().get(&key); + + // Fresh: answer without the network. Stale: send the conditional + // headers (If-None-Match / If-Modified-Since) the policy computed, + // keeping the caller's extensions such as its redirect policy. + let mut outgoing = request.clone(); + if let Some((policy, body)) = &cached { + match policy.before_request(&request, SystemTime::now()) { + BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), + BeforeRequest::Stale { + request: revalidation, + .. + } => { + outgoing.headers = revalidation.headers; + } + } + } + + let (head, body) = inner + .send(Request::from_parts(outgoing, AsyncBody::empty())) + .await? + .into_parts(); + + // 304: keep the cached body under the refreshed headers. + if let Some((policy, cached_body)) = cached { + match policy.after_response(&request, &head, SystemTime::now()) { + AfterResponse::NotModified(policy, head) => { + let mut store = store.lock().unwrap(); + store.insert(key, policy, cached_body.clone()); + return Ok(respond(head, cached_body)); + } + AfterResponse::Modified(..) => {} + } + } + + // `CachePolicy::new` evaluates the response as a shared cache: + // `no-store` and `private` responses, and most responses to requests + // carrying `Authorization`, are not storable. + let policy = CachePolicy::new(&request, &head); + if !policy.is_storable() { + store.lock().unwrap().remove(&key); + return Ok(Response::from_parts(head, body)); + } + + let limit = store.lock().unwrap().max_entry_bytes(); + let mut bytes = Vec::new(); + let mut reader = body.take(limit as u64 + 1); + reader.read_to_end(&mut bytes).await?; + let body = reader.into_inner(); + if bytes.len() > limit { + // Too large to keep: return what was read, then the rest of the stream. + store.lock().unwrap().remove(&key); + let rest = Cursor::new(bytes).chain(body); + return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); + } + + let bytes = Bytes::from(bytes); + store.lock().unwrap().insert(key, policy, bytes.clone()); + Ok(respond(head, bytes)) + } + .boxed() + } +} +``` + +在应用原有的客户端外层安装这个包装。这里的 `reqwest_client` 是 `gpui-pre-reqwest-client` crate,版本与所用 `gpui-kit` 固定的 GPUI 快照一致: + +```rust +use std::sync::Arc; + +gpui_kit::application().run(|cx| { + gpui_kit::init(cx); + + let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") + .expect("failed to create the HTTP client"); + let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); + cx.set_http_client(Arc::new(cached)); + + // Open windows here. +}); +``` + +这个示例刻意保持简短,应用有需要时可以在以下方面扩展: + +- **持久化。** 应用退出后条目随之消失。要跨启动保留图片,可以把每个响应体连同其 `CachePolicy` 存到磁盘(`CachePolicy` 实现了 `serde` 的 trait),以原子方式写入文件,并在启动时清理超出大小或时间限制的条目。基于 `reqwest` 的客户端也可以改用带磁盘存储的缓存中间件,例如 [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest)。 +- **变体。** 每个 URL 只保留一个响应。因 `Vary` 而不同的响应会替换之前的变体。 +- **重复请求。** 同一 URL 的并发未命中会各自访问网络。GPUI 的图片加载器本身已经让同一来源只加载一次。 +- **淘汰策略。** 总是先移除最早存入的条目,不管它最近是否被使用。如果访问模式很重要,可以改用 LRU 结构。 + ## SVG 图片与单色图标 两种形式都能从同一 `AssetSource` 读取键名,但渲染方式不同: @@ -288,7 +513,7 @@ svg().path("icons/check.svg") - 图片尺寸应接近实际显示尺寸。很小的缩略图不需要全屏照片那么多编码像素。 - 压缩源文件,并在目标显示设备上检查效果。照片和线条插画应选择合适的格式;不要假设所有平台或解码器支持所有格式。 - 为稍后才到达的图片保留固定区域或稳定比例。加载态和失败态应占据同一块区域。 -- GPUI 默认的图片缓存已经处理普通的重复来源。只有测得明确的生命周期或内存需求时才引入自定义缓存。 +- GPUI 默认的图片缓存已经处理普通的重复来源。只有测得明确的生命周期或内存需求时才引入自定义缓存。要避免新视图或重启后重新下载远程图片,应在 HTTP 层缓存,见[远程图片的 HTTP 缓存](#远程图片的-http-缓存)。 - 对于大型滚动画廊,用集合 API 只构建可见或邻近的条目。单独调用 `img()` 并不等于实施了懒加载策略。 - 对传达信息的图片提供可见说明或周围界面中的可访问描述。装饰性图片不需重复朗读。由图片驱动的操作应使用带名称、焦点和键盘激活能力的真实控件。 From 85b03f3fcdc83ff8b8de1e27f4699f91591f5974 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 25 Sep 2026 23:33:29 +0800 Subject: [PATCH 2/3] docs: move image details into an Images guide Add docs/image.md, covering img() sources, loading and decoding, sizing, svg(), choosing between them, decoded image caches with a custom ImageCache, and caching remote images over HTTP. Shorten the Image component page to common patterns that link to the guide. Co-Authored-By: Claude Opus 5.5 (1M context) --- website/component/image.md | 415 ++--------------------------- website/docs/assets.md | 2 +- website/docs/image.md | 431 +++++++++++++++++++++++++++++++ website/zh-CN/component/image.md | 415 ++--------------------------- website/zh-CN/docs/assets.md | 2 +- website/zh-CN/docs/image.md | 431 +++++++++++++++++++++++++++++++ 6 files changed, 916 insertions(+), 780 deletions(-) create mode 100644 website/docs/image.md create mode 100644 website/zh-CN/docs/image.md diff --git a/website/component/image.md b/website/component/image.md index 110f19e5be..62052a4d5f 100644 --- a/website/component/image.md +++ b/website/component/image.md @@ -5,10 +5,12 @@ description: Display embedded, local, and remote images with sizing, loading, an # Image -GPUI's `img()` creates an image element. GPUI Kit re-exports it from `gpui_kit`, so an application using the umbrella crate needs no separate GPUI dependency. An image can come from an embedded asset key, a file-system `Path`, an HTTP URL, or in-memory image data. The source type determines where GPUI loads the bytes; styling determines how those bytes are displayed. +GPUI's `img()` draws an image, and `svg()` draws a single-color icon. GPUI Kit re-exports both from `gpui_kit`. This page shows the patterns an application uses most; [Images](../docs/image.md) explains sources, loading, sizing, `svg()`, caching, and HTTP caching in detail. ## Start with a working image +This complete native `src/main.rs` uses an icon already bundled by GPUI Kit, so it needs no extra asset file. Add `gpui-kit = "0.6"` to `Cargo.toml`. The same asset is shown as a color-preserving image and as a monochrome SVG. + This complete native `src/main.rs` uses an icon already bundled by GPUI Kit, so it needs no extra asset file. Add `gpui-kit = "0.6"` to `Cargo.toml`. The same asset is shown as a color-preserving image and as a theme-colored monochrome SVG; the difference is explained below. ```rust @@ -45,23 +47,11 @@ fn main() { } ``` -`with_assets(Assets)` registers the default component icons before opening a window. Replace the source with a composite `AssetSource` when your app also embeds its own images; see [Icons & Assets](../docs/assets.md). The example's `with_loading` callback appears only if loading is still pending after a short delay. `with_fallback` runs when loading fails. The image's stable `.id(...)` lets GPUI keep its loading state across frames. - -## Choose the source - -| Argument to `img(...)` | How GPUI loads it | Packaging implication | -| --- | --- | --- | -| `"images/cover.png"` | Calls the registered `AssetSource` with that exact key | Embed or provide that key in your source. | -| `std::path::Path::new("/absolute/cover.png")` | Reads a file-system path | Ship the file where the app will run, or let the user select it. | -| `"https://example.com/cover.png"` | Fetches a URL through the configured HTTP client | Handle connectivity, loading, and HTTP errors. | -| `Arc` | Decodes caller-supplied encoded bytes and format | Supply an `Image` with matching bytes and format. | -| `Arc` | Uses already renderable image data | The caller creates or retains the renderable data. | - -A non-URL **string** is an asset key, even if it looks like a relative file name. It is not resolved against the process's current directory. To bundle your own `images/cover.png`, include it in an application `AssetSource` and register that source with `with_assets(...)`. For a native `rust-embed` source rooted at `./assets`, the file `assets/images/cover.png` has the key `images/cover.png`. See the [application asset walkthrough](../docs/assets.md) for the source implementation and fallback to component icons. +`with_assets(Assets)` registers the default component icons; register your own `AssetSource` to embed your images (see [Icons & Assets](../docs/assets.md)). A string such as `"images/cover.png"` is an asset key, a `Path` reads a file, and a URL is fetched through the App's `HttpClient`; see [Sources](../docs/image.md#sources). -## Size and fit +## Common patterns -Give the image a useful layout size. `object_fit` determines how its content fits inside those bounds; the default is `Contain`. +A thumbnail that fills a fixed box and crops its edges: ```rust img("images/cover.png") @@ -70,56 +60,27 @@ img("images/cover.png") .object_fit(ObjectFit::Cover) ``` -| Fit | Result | -| --- | --- | -| `Contain` | Show the whole image and preserve its aspect ratio; unused space may remain. | -| `Cover` | Fill the bounds and preserve aspect ratio; edges may be cropped. | -| `Fill` | Stretch to both dimensions, possibly distorting the image. | -| `ScaleDown` | Fit like `Contain` but do not enlarge the source. | -| `None` | Keep the source's original size. | - -Use `Cover` for a cropped thumbnail and `Contain` for a logo or diagram that must remain fully visible. Set both width and height when the surrounding layout needs stable dimensions during loading. - -### Responsive width and a predictable ratio - -Let the surrounding layout decide the available width, cap a large image, and reserve its height before the bytes arrive: +A banner that follows the parent width and reserves its height before the image arrives: ```rust -div() +img("images/banner.webp") .w_full() - .max_w(px(640.)) - .child( - img("images/banner.webp") - .w_full() - .aspect_ratio(16. / 9.) - .object_fit(ObjectFit::Cover), - ) + .aspect_ratio(16. / 9.) + .object_fit(ObjectFit::Cover) ``` -`w_full()` follows the parent width; `max_w(...)` limits the whole image region on a wide window. An explicit `aspect_ratio(...)` reserves a stable box before decoding. For square cards, use `.aspect_ratio(1.)`. Without an explicit ratio or height, GPUI can use the decoded image's intrinsic ratio, but the layout may change when loading finishes. If a flex child refuses to shrink in a narrow window, give its containing pane `.min_w_0()`. - -### A small image grid - -This is a scoped example for a view's `render` method after registering an `AssetSource` with these three keys. It intentionally uses a fixed three-column grid; for a narrow window, change the column count or use a different layout at your app's breakpoint. +A remote image with loading and failure states. The `.id(...)` is required for the loading state to appear: ```rust -div() - .grid() - .grid_cols(3) - .gap_3() - .children([ - "images/one.webp", - "images/two.webp", - "images/three.webp", - ].into_iter().map(|source| { - img(source) - .w_full() - .aspect_ratio(1.) - .object_fit(ObjectFit::Cover) - })) +img("https://example.com/avatar.png") + .id("avatar") + .size(px(48.)) + .rounded_full() + .with_loading(|| div().child("Loading image...").into_any_element()) + .with_fallback(|| div().child("Image unavailable").into_any_element()) ``` -Every tile reserves the same square area while loading. `Cover` can crop important content, so use `Contain` for diagrams or product images whose edges must remain visible. For a long, changing collection, use a scrolling or virtualized collection rather than constructing every tile in one frame. +Use `ObjectFit::Contain` (the default) for logos and diagrams that must stay fully visible. To keep a multicolor SVG's colors, draw it with `img()`; for an icon that follows the theme, use [Icon](./icon.md). See [Size and fit](../docs/image.md#size-and-fit) and [img() or svg()](../docs/image.md#img-or-svg). ## Gallery with selection @@ -193,338 +154,14 @@ fn main() { The selected button has an explicit selected state, and the visible `Selected: ...` text reports the current choice. In a product gallery, use meaningful labels such as `Show front view` rather than a file name. If the collection can be empty, render an empty-state message before indexing it. If image sources can be removed or reordered, store a stable domain ID instead of an index and resolve it during render. -## Hero image with readable copy - -Place copy in a separate surface above the image rather than relying on pixels in the photo for contrast. This scoped example assumes `cx` is the view's context and `ActiveTheme` is imported. - -```rust -use gpui_kit::component::ActiveTheme as _; - -div() - .relative() - .w_full() - .max_w(px(720.)) - .h(px(320.)) - .overflow_hidden() - .child( - img("images/hero.webp") - .absolute() - .inset_0() - .size_full() - .object_fit(ObjectFit::Cover), - ) - .child( - div() - .absolute() - .bottom_0() - .left_0() - .p_4() - .bg(cx.theme().background) - .child("Explore the collection"), - ) -``` - -The image is decorative here; the text carries the meaning. Keep any action outside a clipped or obscured region so its keyboard focus indication stays visible. - -## Loading and failures - -`img()` exposes real loading and error hooks through `StyledImage`. The callbacks return an element to display in the image's place. This example uses an embedded key; the same pattern works for URLs and file-system paths. - -```rust -img("images/cover.png") - .id("cover-image") - .w(px(320.)) - .h(px(180.)) - .object_fit(ObjectFit::Cover) - .with_loading(|| div().child("Loading image...").into_any_element()) - .with_fallback(|| div().child("Image unavailable").into_any_element()) -``` - -A quick load may finish before the loading view appears. A missing asset key, invalid image bytes, a missing local file, or an unsuccessful HTTP response can lead to the fallback. Keep the outer layout size stable so a replacement view does not unexpectedly move nearby content. GPUI caches loaded image resources; use a dedicated image cache only when your view has a specific cache lifetime requirement. - -`with_loading` and `with_fallback` supply replacement elements; they do not expose a separate `ImageState` enum or automatically add a retry command. If a remote image is essential to the task, provide a nearby retry action in your application's state and rebuild the image with an updated source when the user retries. Avoid showing a fake loading skeleton after the source has already failed. - -## Cache remote images over HTTP - -GPUI loads a URL source such as `img("https://...")`, and a remote image in a `TextView` document, through the `HttpClient` installed on the `App`. On native platforms the application chooses that client: the default one fails every request, so install one with `cx.set_http_client(...)` or `Application::with_http_client(...)` before remote images can load. On the web, `gpui_kit::application()` installs a client backed by the browser's Fetch API, and the browser's HTTP cache already applies; this section is about native applications. - -GPUI's image cache sits above that client. It keeps decoded images in memory, keyed by source, which is enough for repeated sources in a running view but not for network traffic: - -- It ends with the process, so every launch downloads every image again. -- It ignores `Cache-Control`, `ETag`, and `Last-Modified`. It cannot keep a response the server allows to be reused, or ask the server whether an older copy is still current. -- It lives only as long as the cache that holds it. An image with its own `.image_cache(...)`, or a loader that keeps a cache per view, requests the image again each time a new view is created. - -To reuse responses across views and launches, wrap the application's `HttpClient` in a client that applies HTTP caching rules, and install the wrapper once at startup. Every remote image then benefits, including images loaded by code the application does not own. Follow these rules: - -- **Cache only a GET without a request body.** Pass every other request through unchanged. -- **Treat the cache as shared.** One client serves the whole application: its own views, extensions, and document views. Do not store a `no-store` or `private` response, or a response to a request carrying `Authorization`, unless the server explicitly allows a shared cache to keep it. A layer below the cache that adds cookies or tokens hides them from the cache, so add credentials above the cache, or leave those hosts uncached. -- **Revalidate instead of downloading again.** Serve a fresh response without the network. When it is stale, send `If-None-Match` or `If-Modified-Since`; on `304 Not Modified`, return the stored body with the refreshed headers. -- **Honor the caller's redirect policy.** `img()` follows redirects, but a loader that authorizes each hop itself requests `RedirectPolicy::NoFollow` and must receive the `3xx` response. Keep the policy in the cache key so a followed result never answers that caller, and cache the redirect response itself by the same rules. -- **Bound memory and disk use.** Cap each entry and the total size, evict old entries, and stream a response that is too large to keep straight through. -- **Authorize before the request.** The cache answers any caller that asks for the same URL. A check that decides whether a caller may reach a URL, such as the network grants of gpui-shell scripts, must run before `send`. The cache then never widens what a caller can reach; it only avoids repeating a request that was already allowed. - -The example below applies these rules in memory. The [http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate implements the HTTP caching rules: freshness, validators, `Vary`, and the restrictions on a shared cache. Add `http-cache-semantics = "2"`, `futures`, `bytes`, and `anyhow` to `Cargo.toml`. - -```rust -use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex}, - time::SystemTime, -}; - -use bytes::Bytes; -use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; -use gpui_kit::http_client::{ - AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, - http::{HeaderValue, response}, -}; -use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; - -/// An [`HttpClient`] that answers GET requests from memory when HTTP caching -/// rules allow it, and forwards everything else to `inner`. -pub struct CachingHttpClient { - inner: Arc, - store: Arc>, -} - -impl CachingHttpClient { - pub fn new(inner: Arc, max_bytes: usize) -> Self { - let store = Store { - max_bytes, - ..Default::default() - }; - Self { - inner, - store: Arc::new(Mutex::new(store)), - } - } -} - -/// The URI plus the caller's redirect policy. A caller that disables -/// redirects must receive the 3xx itself, never a followed result. -type Key = (String, Option); - -struct Entry { - policy: CachePolicy, - body: Bytes, -} - -#[derive(Default)] -struct Store { - entries: HashMap, - /// Keys in insertion order; the oldest is evicted first. - order: VecDeque, - bytes: usize, - max_bytes: usize, -} - -impl Store { - /// A single response may use at most an eighth of the budget. - fn max_entry_bytes(&self) -> usize { - self.max_bytes / 8 - } - - fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { - let entry = self.entries.get(key)?; - Some((entry.policy.clone(), entry.body.clone())) - } - - fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { - self.remove(&key); - self.bytes += body.len(); - self.order.push_back(key.clone()); - self.entries.insert(key, Entry { policy, body }); - while self.bytes > self.max_bytes { - let Some(oldest) = self.order.pop_front() else { - break; - }; - if let Some(entry) = self.entries.remove(&oldest) { - self.bytes -= entry.body.len(); - } - } - } - - fn remove(&mut self, key: &Key) { - if let Some(entry) = self.entries.remove(key) { - self.bytes -= entry.body.len(); - self.order.retain(|k| k != key); - } - } -} - -fn respond(head: response::Parts, body: Bytes) -> Response { - Response::from_parts(head, AsyncBody::from_bytes(body)) -} - -impl HttpClient for CachingHttpClient { - fn user_agent(&self) -> Option<&HeaderValue> { - self.inner.user_agent() - } - - fn proxy(&self) -> Option<&Url> { - self.inner.proxy() - } - - fn send( - &self, - req: Request, - ) -> BoxFuture<'static, anyhow::Result>> { - // Only a GET without a body is cacheable; pass everything else through. - if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { - return self.inner.send(req); - } - - let inner = self.inner.clone(); - let store = self.store.clone(); - async move { - let (request, _) = req.into_parts(); - let redirects = request.extensions.get::().cloned(); - let key = (request.uri.to_string(), redirects); - let cached = store.lock().unwrap().get(&key); - - // Fresh: answer without the network. Stale: send the conditional - // headers (If-None-Match / If-Modified-Since) the policy computed, - // keeping the caller's extensions such as its redirect policy. - let mut outgoing = request.clone(); - if let Some((policy, body)) = &cached { - match policy.before_request(&request, SystemTime::now()) { - BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), - BeforeRequest::Stale { - request: revalidation, - .. - } => { - outgoing.headers = revalidation.headers; - } - } - } - - let (head, body) = inner - .send(Request::from_parts(outgoing, AsyncBody::empty())) - .await? - .into_parts(); - - // 304: keep the cached body under the refreshed headers. - if let Some((policy, cached_body)) = cached { - match policy.after_response(&request, &head, SystemTime::now()) { - AfterResponse::NotModified(policy, head) => { - let mut store = store.lock().unwrap(); - store.insert(key, policy, cached_body.clone()); - return Ok(respond(head, cached_body)); - } - AfterResponse::Modified(..) => {} - } - } - - // `CachePolicy::new` evaluates the response as a shared cache: - // `no-store` and `private` responses, and most responses to requests - // carrying `Authorization`, are not storable. - let policy = CachePolicy::new(&request, &head); - if !policy.is_storable() { - store.lock().unwrap().remove(&key); - return Ok(Response::from_parts(head, body)); - } - - let limit = store.lock().unwrap().max_entry_bytes(); - let mut bytes = Vec::new(); - let mut reader = body.take(limit as u64 + 1); - reader.read_to_end(&mut bytes).await?; - let body = reader.into_inner(); - if bytes.len() > limit { - // Too large to keep: return what was read, then the rest of the stream. - store.lock().unwrap().remove(&key); - let rest = Cursor::new(bytes).chain(body); - return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); - } - - let bytes = Bytes::from(bytes); - store.lock().unwrap().insert(key, policy, bytes.clone()); - Ok(respond(head, bytes)) - } - .boxed() - } -} -``` - -Install the wrapper around the client the application already uses. Here `reqwest_client` is the `gpui-pre-reqwest-client` crate at the GPUI snapshot version your `gpui-kit` release pins: - -```rust -use std::sync::Arc; - -gpui_kit::application().run(|cx| { - gpui_kit::init(cx); - - let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") - .expect("failed to create the HTTP client"); - let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); - cx.set_http_client(Arc::new(cached)); - - // Open windows here. -}); -``` - -The example keeps its scope small. Extend it where your application needs more: - -- **Persistence.** Entries disappear when the application quits. To keep images across launches, store each body with its `CachePolicy` on disk (`CachePolicy` implements `serde` traits), write files atomically, and remove entries past a size or age limit at startup. A client built on `reqwest` can instead use caching middleware with a disk store, such as [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest). -- **Variants.** Each URL keeps one response. A response that differs by `Vary` replaces the previous variant. -- **Duplicate requests.** Concurrent misses for the same URL each reach the network. GPUI's image loader already shares one load per source. -- **Eviction.** The oldest entry is removed first, whether or not it was used recently. Use an LRU structure when access patterns matter. - -## SVG as an image or an icon - -Both forms can load a key from the same `AssetSource`, but they render differently: - -| API | Rendering | Use it for | -| --- | --- | --- | -| `img("images/brand.svg")` | Rasterizes the SVG as an image and keeps its source colors. `object_fit` applies. | Multicolor logos, illustrations, and diagrams. | -| `svg().path("icons/check.svg")` | Renders the SVG's alpha as a monochrome mask, colored by the element's text color. | Single-color icons that follow a theme or state. | - -```rust -img("images/brand.svg") - .size(px(96.)) - .object_fit(ObjectFit::Contain); - -svg().path("icons/check.svg") - .size(px(20.)) - .text_color(rgb(0x2563eb)); -``` - -Applying `.text_color(...)` to `img("images/brand.svg")` does **not** recolor the image. Use `svg().path(...)` or GPUI Kit's [`Icon`](./icon.md) for a monochrome icon. For a small SVG provided directly as bytes, `svg().data(include_bytes!("check.svg"))` skips the asset-path lookup. A full-color SVG still belongs in `img()`. - -`img()` supports common raster formats such as PNG, JPEG, WebP, and GIF, plus SVG. Format support comes from the pinned GPUI image decoder; check your target platform with representative files before shipping. - -## API quick reference - -| API | Purpose | Note | -| --- | --- | --- | -| `img(source)` | Create an image from an `ImageSource` | A non-URL string is an embedded asset key. | -| `.id(id)` | Give the element stable identity | Useful for image loading state across renders. | -| `.w(...)`, `.h(...)`, `.size(...)` | Set explicit dimensions | Reserve space before a remote image loads. | -| `.w_full()`, `.h_full()`, `.size_full()` | Fill the parent in that axis | The parent still needs useful bounds. | -| `.max_w(...)`, `.aspect_ratio(...)` | Cap width and reserve proportions | Helpful for responsive layouts. | -| `.object_fit(ObjectFit::...)` | Choose crop and scaling behavior | Defaults to `Contain`. | -| `.with_loading(...)`, `.with_fallback(...)` | Supply replacement elements | Each callback returns `AnyElement`. | -| `.grayscale(true)` | Render a desaturated image | Visual treatment does not change the source. | -| `.image_cache(&cache)` | Override cache for this image | Use only when a specific cache lifetime is needed. | - -`rounded(...)`, borders, `overflow_hidden()`, and shadows are ordinary element/container styling, not image decoding options. Clip an image inside a rounded parent when the crop must follow that shape. - -## Performance and accessibility - -- Supply image dimensions close to the displayed size. A tiny thumbnail does not need the same encoded pixels as a full-screen photo. -- Compress source files and verify their appearance on your target displays. Use a suitable format for photos versus line art; do not assume every platform or decoder accepts every format. -- Keep a stable ratio or fixed bounds for images that arrive later. The loading and failure views should occupy the same region. -- GPUI's default image cache already handles ordinary repeated sources. Introduce a custom cache only for a measured lifetime or memory requirement. To avoid downloading remote images again in new views or after a restart, cache at the HTTP layer instead; see [Cache remote images over HTTP](#cache-remote-images-over-http). -- For a large scrolling gallery, create only the visible or nearby items using the collection APIs. The `img()` call alone is not a lazy-loading policy. -- Pair informative images with visible descriptive text or an accessible description in the surrounding UI. Decorative images need no duplicated narration. Make image-driven commands real controls with names, focus, and keyboard activation. +## Accessibility -## Troubleshooting +- Pair an informative image with visible text or an accessible description in the surrounding UI. A file name is not a description. Decorative images need no narration. +- Make an image that triggers a command a real control, such as a `Button`, so it has a name, focus, and keyboard activation. +- Keep the loading and failure views in the same box as the image, so nearby content does not move. -| Symptom | Check | -| --- | --- | -| Blank embedded image | Confirm that the registered `AssetSource` includes the exact key and file bytes. `img("images/cover.png")` is an embedded lookup, not a file-system read. | -| Default icon missing | Register GPUI Kit's default `Assets` or compose it after your app source. | -| Full-color SVG changes to one color | Render it with `img()`; `svg().path(...)` intentionally uses an alpha mask. | -| Image appears cropped | Use `ObjectFit::Contain`, or increase the display bounds. | -| Fallback appears | Check the file path, network response, or decoded bytes for the chosen source type. | +## Learn more -For an image that conveys information, provide adjacent text or another accessible description in the surrounding UI. A filename is not a user-facing description. +- [Images](../docs/image.md): every `img()` source, loading and decoding, `svg()`, and troubleshooting. +- [Caches for decoded images](../docs/image.md#caches-for-decoded-images): scoped and custom `ImageCache`s. +- [Cache remote images over HTTP](../docs/image.md#cache-remote-images-over-http): reuse downloads across views and launches. diff --git a/website/docs/assets.md b/website/docs/assets.md index 99b61bf141..6e4434d903 100644 --- a/website/docs/assets.md +++ b/website/docs/assets.md @@ -285,7 +285,7 @@ let color_artwork = img("images/illustration.svg").size(px(160.)); The `img()` source supports common PNG, JPEG, WebP, GIF, and SVG files (and other formats listed by GPUI's `Img::extensions()`). It detects raster formats from the bytes; SVG takes a separate decoding path. Use a real image file with a supported format, not only a matching extension. `ObjectFit::Contain` is the default; `Cover` fills the bounds and may crop, while `Fill` can distort. `object_fit` controls `img()`, not the monochrome `svg()` element. `img()` loads and decodes asynchronously through GPUI's image cache; embedded source bytes are copied into its loader, so embedding alone does not eliminate decode or runtime image memory. Animated GIF and WebP can contain multiple frames. -For a string like `"images/cover.png"`, GPUI calls the registered `AssetSource` with that key. `img(std::path::Path::new("/absolute/file.png"))` reads a filesystem path, and a URL string uses the HTTP loader. They have different deployment and error behavior. See [Image](../component/image.md) for more sizing and fitting examples. +For a string like `"images/cover.png"`, GPUI calls the registered `AssetSource` with that key. `img(std::path::Path::new("/absolute/file.png"))` reads a filesystem path, and a URL string uses the HTTP loader. They have different deployment and error behavior. See [Images](./image.md) for how each source loads, sizes, and caches. ## Embed individual SVG icons diff --git a/website/docs/image.md b/website/docs/image.md new file mode 100644 index 0000000000..a04836ffab --- /dev/null +++ b/website/docs/image.md @@ -0,0 +1,431 @@ +--- +title: Images +description: How img() and svg() load, decode, size, and cache images, and how to cache remote images over HTTP. +order: -6.9 +--- + +# Images + +GPUI draws images with two elements. `img()` draws a full-color image: a photo, a screenshot, an avatar, or a multicolor SVG. `svg()` draws a single-color SVG as a mask filled with a text color, which is how icons follow the theme. Both are re-exported from `gpui_kit`. This page explains how each element finds, decodes, sizes, and caches its source, and how to keep remote images from being downloaded again. For ready-made UI patterns see the [Image](../component/image.md) component page; for bundling files into the binary see [Icons & Assets](./assets.md). + +## Sources + +`img(source)` takes anything that converts into `ImageSource`. The conversion decides where the bytes come from: + +| Argument | `ImageSource` | Where the bytes come from | +| --- | --- | --- | +| `"https://example.com/a.png"`, or any string that parses as a URL; a `SharedUri` | `Resource(Resource::Uri)` | The `HttpClient` installed on the `App` | +| `"images/a.png"`, or any other string | `Resource(Resource::Embedded)` | The registered `AssetSource`, looked up by that exact key | +| `&Path`, `PathBuf`, `Arc` | `Resource(Resource::Path)` | The file system | +| `Arc` | `Image` | Encoded bytes you already hold, with their `ImageFormat` | +| `Arc` | `Render` | Frames you already decoded; drawn as is | +| `Fn(&mut Window, &mut App) -> Option, ImageCacheError>>` | `Custom` | Your own loader | + +A string is a URL whenever it parses as one, and an asset key otherwise. A relative-looking string such as `"images/a.png"` is never read from the working directory. Pass a `Path` for a file on disk, so it is never taken for a URL or an asset key. + +On native platforms the default `HttpClient` fails every request, so URL images load only after the application installs a client with `cx.set_http_client(...)` or `Application::with_http_client(...)`. On the web, `gpui_kit::application()` installs a client backed by the browser's Fetch API. + +A `Custom` loader runs during layout and paint of every frame. Return `None` while the image is loading and keep the result yourself, for example with `window.use_asset::(...)`, so that each frame does not start a new load. + +## Loading, decoding, and failures + +Loading is asynchronous. While it is pending, `img()` lays out from its own style and draws nothing. When the load finishes, GPUI redraws the view that drew the image. + +```rust +img("https://example.com/cover.png") + .id("cover") + .w(px(320.)) + .h(px(180.)) + .with_loading(|| div().child("Loading image...").into_any_element()) + .with_fallback(|| div().child("Image unavailable").into_any_element()) +``` + +- `with_loading` replaces the image only when the load is still pending 200 ms after it started, so a fast load never flashes a placeholder. GPUI tracks that time in the element's state, so the placeholder appears only on an image with an `.id(...)`. +- `with_fallback` replaces the image when loading fails: a missing asset key or file, a network error, a response that is not `2xx`, or bytes that do not decode. +- Neither callback retries. To retry, keep the attempt in your view state and draw the image again with a different source, or remove the cached failure (see [Caches for decoded images](#caches-for-decoded-images)). + +GPUI detects the format from the bytes, not from the file extension. PNG, JPEG, WebP, GIF, BMP, TIFF, ICO and the other formats listed by `Img::extensions()` decode through the `image` crate. Bytes that are not a known raster format are parsed as SVG and rasterized once at twice their intrinsic size, so `img()` of an SVG stays sharp at normal scales but blurs when it is enlarged far beyond its own size. + +Animated GIF and WebP images play only on an image with an `.id(...)`, because the current frame is kept in element state. They advance while the window is active and stop when the system asks to reduce motion. + +## Size and fit + +Layout decides the image's bounds; `object_fit` decides how the image is drawn inside them. + +When a dimension is `auto`, GPUI fills it in after the image has decoded. If the other dimension has an absolute length, the image's ratio gives this one; otherwise the image's own size is used. The image's ratio also becomes the element's `aspect_ratio` unless you set one. Before decoding finishes there is nothing to measure, so an image without a size moves the surrounding layout when it arrives. Reserve its box with an explicit size, or with a width and `aspect_ratio(...)`: + +```rust +img("images/banner.webp") + .w_full() + .aspect_ratio(16. / 9.) + .object_fit(ObjectFit::Cover) +``` + +| `ObjectFit` | Result | +| --- | --- | +| `Contain` (default) | The whole image, aspect ratio kept; empty space may remain. | +| `Cover` | Fills the bounds, aspect ratio kept; edges may be cropped. | +| `Fill` | Stretches to the bounds; may distort. | +| `ScaleDown` | Like `Contain`, but never enlarges the image. | +| `None` | The image's own size, centered. | + +`.rounded(...)` on an `img()` rounds the drawn image itself. `.grayscale(true)` draws it without color. Borders, shadows and backgrounds are ordinary element styles. + +## svg() + +`svg()` draws an SVG as a single-color shape. GPUI rasterizes the SVG's alpha channel at the element's size and fills it with a color, so the SVG's own colors are discarded. Choose the source with one of three builders: + +| Builder | Where the bytes come from | +| --- | --- | +| `.path("icons/check.svg")` | The registered `AssetSource`, by key | +| `.external_path("/path/to/check.svg")` | The file system, read asynchronously and cached by path | +| `.data(bytes)` | Bytes you pass in, cached by a hash of the bytes | + +```rust +svg() + .path("icons/check.svg") + .size(px(16.)) + .text_color(cx.theme().foreground) +``` + +- **Set the color on the element.** `svg()` paints only when the element itself has a text color. A color inherited from a parent does not count, so an `svg()` without `.text_color(...)` draws nothing. +- **Set the size.** Layout never reads the SVG, so `svg()` has no intrinsic size and collapses to zero without one. +- **Transform at paint time.** `.with_transformation(Transformation::rotate(percentage(0.25)))`, and the `scale` and `translate` variants, move only the drawing. Layout and the hit area stay where they were. + +In GPUI Kit, prefer the [Icon](../component/icon.md) component for icons: it picks the size from the component size scale and the color from the theme. + +## img() or svg() + +| | `img()` | `svg()` | +| --- | --- | --- | +| Colors | Keeps the source's colors | One color, from `.text_color(...)` | +| Formats | Raster formats and SVG | SVG only | +| Size | Intrinsic size from the image | Must be set | +| Sources | URL, asset key, path, bytes, decoded frames, custom loader | Asset key, path, bytes | +| Loading and failure | `with_loading`, `with_fallback` | Draws nothing until ready or on failure | +| Use for | Photos, avatars, logos, illustrations | Icons and glyphs that follow the theme or a state | + +`.text_color(...)` does not recolor an `img()`, and `svg()` cannot keep a multicolor logo's colors. + +## Caches for decoded images + +Decoded images are kept so that drawing the same source again does not load it again. Which cache keeps them decides when they are released. + +- **Default.** An `img()` of a `Resource` (URL, asset key, or path) uses the App's asset cache, keyed by the source. One entry serves every window and view and stays until you remove it with `ImageSource::remove_asset(cx)`. Failed loads are cached too, so remove the entry before retrying the same source. An `Arc` is cached the same way. +- **A scoped cache.** `image_cache(provider)` wraps children in an element whose `img()` descendants use that cache instead. `image_cache(retain_all("preview"))` keeps a `RetainAllImageCache` in element state: it holds everything it loaded and releases it when the element stops being drawn. `img(...).image_cache(&cache)` picks a cache for one image. +- **Your own cache.** Implement `ImageCache` to decide what to keep. `ImageCacheItem::new(resource, cx)` starts a load through GPUI's image loader, and `item.use_image(window)` returns the result and redraws the current view when it finishes. When you evict an image, release its GPU texture with `cx.drop_image(image, Some(window))`. + +The cache below keeps the most recently drawn images and releases the rest. Its capacity must exceed the number of images on screen at once, or visible images will be evicted and reloaded every frame. + +```rust +use std::{collections::VecDeque, sync::Arc}; + +use gpui_kit::*; + +/// Keeps the `capacity` most recently drawn images and releases the rest. +pub struct RecentImageCache { + capacity: usize, + items: VecDeque<(Resource, ImageCacheItem)>, +} + +impl RecentImageCache { + pub fn new(capacity: usize, cx: &mut App) -> Entity { + let cache = cx.new(|_| Self { + capacity, + items: VecDeque::new(), + }); + // Release the GPU textures when the cache itself is dropped. + cx.observe_release(&cache, |cache, cx| { + for (_, item) in cache.items.drain(..) { + if let Some(Ok(image)) = item.get() { + cx.drop_image(image, None); + } + } + }) + .detach(); + cache + } +} + +impl ImageCache for RecentImageCache { + fn load( + &mut self, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + let item = match self.items.iter().position(|(source, _)| source == resource) { + Some(ix) => self.items.remove(ix).expect("index is in bounds"), + None => (resource.clone(), ImageCacheItem::new(resource, cx)), + }; + self.items.push_front(item); + while self.items.len() > self.capacity { + if let Some((_, evicted)) = self.items.pop_back() + && let Some(Ok(image)) = evicted.get() + { + cx.drop_image(image, Some(window)); + } + } + self.items[0].1.use_image(window) + } +} +``` + +Create it once, keep the `Entity` in your view, and wrap the images that should use it: + +```rust +image_cache(self.images.clone()) + .flex() + .gap_2() + .children(self.urls.iter().map(|url| img(url.clone()).size(px(96.)))) +``` + +These caches hold decoded images in memory. Every one of them still loads a URL through the App's `HttpClient`, and none of them remembers what the server said about the response. That is the job of the next section. + +## Cache remote images over HTTP + +The previous sections cover what `img()` keeps in memory. This section covers the network: how to reuse responses across views and launches on native platforms. On the web the browser's HTTP cache already applies. + +### Why GPUI's image cache is not enough + +GPUI's image cache sits above the App's `HttpClient`. It keeps decoded images in memory, keyed by source, which is enough for repeated sources in a running view but not for network traffic: + +- It ends with the process, so every launch downloads every image again. +- It ignores `Cache-Control`, `ETag`, and `Last-Modified`. It cannot keep a response the server allows to be reused, or ask the server whether an older copy is still current. +- It lives only as long as the cache that holds it. An image with its own `.image_cache(...)`, or a loader that keeps a cache per view, requests the image again each time a new view is created. + +### Cache at the HTTP layer + +To reuse responses across views and launches, wrap the application's `HttpClient` in a client that applies HTTP caching rules, and install the wrapper once at startup. Every remote image then benefits, including images loaded by code the application does not own. Follow these rules: + +- **Cache only a GET without a request body.** Pass every other request through unchanged. +- **Treat the cache as shared.** One client serves the whole application: its own views, extensions, and document views. Do not store a `no-store` or `private` response, or a response to a request carrying `Authorization`, unless the server explicitly allows a shared cache to keep it. A layer below the cache that adds cookies or tokens hides them from the cache, so add credentials above the cache, or leave those hosts uncached. +- **Revalidate instead of downloading again.** Serve a fresh response without the network. When it is stale, send `If-None-Match` or `If-Modified-Since`; on `304 Not Modified`, return the stored body with the refreshed headers. +- **Honor the caller's redirect policy.** `img()` follows redirects, but a loader that authorizes each hop itself requests `RedirectPolicy::NoFollow` and must receive the `3xx` response. Keep the policy in the cache key so a followed result never answers that caller, and cache the redirect response itself by the same rules. +- **Bound memory and disk use.** Cap each entry and the total size, evict old entries, and stream a response that is too large to keep straight through. +- **Authorize before the request.** The cache answers any caller that asks for the same URL. A check that decides whether a caller may reach a URL, such as the network grants of gpui-shell scripts, must run before `send`. The cache then never widens what a caller can reach; it only avoids repeating a request that was already allowed. + +### Example + +The example below applies these rules in memory. The [http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate implements the HTTP caching rules: freshness, validators, `Vary`, and the restrictions on a shared cache. Add `http-cache-semantics = "2"`, `futures`, `bytes`, and `anyhow` to `Cargo.toml`. + +```rust +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use bytes::Bytes; +use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; +use gpui_kit::http_client::{ + AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, + http::{HeaderValue, response}, +}; +use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; + +/// An [`HttpClient`] that answers GET requests from memory when HTTP caching +/// rules allow it, and forwards everything else to `inner`. +pub struct CachingHttpClient { + inner: Arc, + store: Arc>, +} + +impl CachingHttpClient { + pub fn new(inner: Arc, max_bytes: usize) -> Self { + let store = Store { + max_bytes, + ..Default::default() + }; + Self { + inner, + store: Arc::new(Mutex::new(store)), + } + } +} + +/// The URI plus the caller's redirect policy. A caller that disables +/// redirects must receive the 3xx itself, never a followed result. +type Key = (String, Option); + +struct Entry { + policy: CachePolicy, + body: Bytes, +} + +#[derive(Default)] +struct Store { + entries: HashMap, + /// Keys in insertion order; the oldest is evicted first. + order: VecDeque, + bytes: usize, + max_bytes: usize, +} + +impl Store { + /// A single response may use at most an eighth of the budget. + fn max_entry_bytes(&self) -> usize { + self.max_bytes / 8 + } + + fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { + let entry = self.entries.get(key)?; + Some((entry.policy.clone(), entry.body.clone())) + } + + fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { + self.remove(&key); + self.bytes += body.len(); + self.order.push_back(key.clone()); + self.entries.insert(key, Entry { policy, body }); + while self.bytes > self.max_bytes { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(entry) = self.entries.remove(&oldest) { + self.bytes -= entry.body.len(); + } + } + } + + fn remove(&mut self, key: &Key) { + if let Some(entry) = self.entries.remove(key) { + self.bytes -= entry.body.len(); + self.order.retain(|k| k != key); + } + } +} + +fn respond(head: response::Parts, body: Bytes) -> Response { + Response::from_parts(head, AsyncBody::from_bytes(body)) +} + +impl HttpClient for CachingHttpClient { + fn user_agent(&self) -> Option<&HeaderValue> { + self.inner.user_agent() + } + + fn proxy(&self) -> Option<&Url> { + self.inner.proxy() + } + + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + // Only a GET without a body is cacheable; pass everything else through. + if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { + return self.inner.send(req); + } + + let inner = self.inner.clone(); + let store = self.store.clone(); + async move { + let (request, _) = req.into_parts(); + let redirects = request.extensions.get::().cloned(); + let key = (request.uri.to_string(), redirects); + let cached = store.lock().unwrap().get(&key); + + // Fresh: answer without the network. Stale: send the conditional + // headers (If-None-Match / If-Modified-Since) the policy computed, + // keeping the caller's extensions such as its redirect policy. + let mut outgoing = request.clone(); + if let Some((policy, body)) = &cached { + match policy.before_request(&request, SystemTime::now()) { + BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), + BeforeRequest::Stale { + request: revalidation, + .. + } => { + outgoing.headers = revalidation.headers; + } + } + } + + let (head, body) = inner + .send(Request::from_parts(outgoing, AsyncBody::empty())) + .await? + .into_parts(); + + // 304: keep the cached body under the refreshed headers. + if let Some((policy, cached_body)) = cached { + match policy.after_response(&request, &head, SystemTime::now()) { + AfterResponse::NotModified(policy, head) => { + let mut store = store.lock().unwrap(); + store.insert(key, policy, cached_body.clone()); + return Ok(respond(head, cached_body)); + } + AfterResponse::Modified(..) => {} + } + } + + // `CachePolicy::new` evaluates the response as a shared cache: + // `no-store` and `private` responses, and most responses to requests + // carrying `Authorization`, are not storable. + let policy = CachePolicy::new(&request, &head); + if !policy.is_storable() { + store.lock().unwrap().remove(&key); + return Ok(Response::from_parts(head, body)); + } + + let limit = store.lock().unwrap().max_entry_bytes(); + let mut bytes = Vec::new(); + let mut reader = body.take(limit as u64 + 1); + reader.read_to_end(&mut bytes).await?; + let body = reader.into_inner(); + if bytes.len() > limit { + // Too large to keep: return what was read, then the rest of the stream. + store.lock().unwrap().remove(&key); + let rest = Cursor::new(bytes).chain(body); + return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); + } + + let bytes = Bytes::from(bytes); + store.lock().unwrap().insert(key, policy, bytes.clone()); + Ok(respond(head, bytes)) + } + .boxed() + } +} +``` + +### Install the client + +Install the wrapper around the client the application already uses. Here `reqwest_client` is the `gpui-pre-reqwest-client` crate at the GPUI snapshot version your `gpui-kit` release pins: + +```rust +use std::sync::Arc; + +gpui_kit::application().run(|cx| { + gpui_kit::init(cx); + + let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") + .expect("failed to create the HTTP client"); + let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); + cx.set_http_client(Arc::new(cached)); + + // Open windows here. +}); +``` + +### Extend the example + +The example keeps its scope small. Extend it where your application needs more: + +- **Persistence.** Entries disappear when the application quits. To keep images across launches, store each body with its `CachePolicy` on disk (`CachePolicy` implements `serde` traits), write files atomically, and remove entries past a size or age limit at startup. A client built on `reqwest` can instead use caching middleware with a disk store, such as [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest). +- **Variants.** Each URL keeps one response. A response that differs by `Vary` replaces the previous variant. +- **Duplicate requests.** Concurrent misses for the same URL each reach the network. GPUI's image loader already shares one load per source. +- **Eviction.** The oldest entry is removed first, whether or not it was used recently. Use an LRU structure when access patterns matter. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| An embedded image is blank | The registered `AssetSource` must contain the exact key. `img("images/a.png")` is an asset lookup, not a file read. | +| Every URL image shows the fallback on native | Install an `HttpClient`; the default one fails every request. | +| `with_loading` never appears, or a GIF does not play | Give the `img()` an `.id(...)`. | +| An `svg()` is invisible | Set `.text_color(...)` and a size on the `svg()` element itself. | +| A multicolor SVG turns into one color | Draw it with `img()`; `svg()` always draws a single color. | +| The layout jumps when an image arrives | Reserve its box with a size, or a width and `aspect_ratio(...)`. | +| A fixed image still shows the old failure | The failure is cached; call `ImageSource::remove_asset(cx)` before drawing it again. | +| Images download again in every new view or after a restart | Cache at the HTTP layer; see [Cache remote images over HTTP](#cache-remote-images-over-http). | diff --git a/website/zh-CN/component/image.md b/website/zh-CN/component/image.md index 2018f00871..5061980788 100644 --- a/website/zh-CN/component/image.md +++ b/website/zh-CN/component/image.md @@ -5,10 +5,12 @@ description: 展示嵌入资源、本地文件与远程图片,并处理尺寸 # Image -GPUI 的 `img()` 会创建图片元素。GPUI Kit 从 `gpui_kit` 重新导出这一 API,使用整合 crate 的应用无需另加 GPUI 依赖。图片可以来自嵌入式资源键名、文件系统 `Path`、HTTP URL 或内存中的图片数据。参数类型决定 GPUI 从哪里取字节;布局样式决定图片如何显示。 +GPUI 的 `img()` 绘制图片,`svg()` 绘制单色图标。GPUI Kit 从 `gpui_kit` 重新导出这两个函数。本页列出应用中最常用的写法;来源、加载、尺寸、`svg()`、缓存和 HTTP 缓存的细节见[图片](../docs/image.md)。 ## 从可运行示例开始 +以下是完整的原生桌面 `src/main.rs`。它使用 GPUI Kit 自带的图标,不需要额外图片文件。在 `Cargo.toml` 加入 `gpui-kit = "0.6"`。同一份资源分别以保留原色的图片和单色 SVG 显示。 + 以下是完整的原生桌面 `src/main.rs`。它使用 GPUI Kit 自带的图标,不需要额外图片文件。在 `Cargo.toml` 加入 `gpui-kit = "0.6"`。同一份 SVG 分别以保留原色的图片和随主题着色的单色图标显示;两者区别见下文。 ```rust @@ -45,23 +47,11 @@ fn main() { } ``` -`with_assets(Assets)` 在打开窗口前注册默认组件图标。如果应用还需要嵌入自己的图片,可以改为注册组合后的 `AssetSource`;见[图标与资源](../docs/assets.md)。只有加载在短暂延迟后仍未完成时,`with_loading` 的内容才会显示;加载失败时显示 `with_fallback` 的内容。稳定的 `.id(...)` 让 GPUI 能跨帧保存图片的加载状态。 - -## 选择图片来源 - -| `img(...)` 参数 | GPUI 的加载方式 | 打包时需要考虑 | -| --- | --- | --- | -| `"images/cover.png"` | 把键名原样交给已注册的 `AssetSource` | 在资源源中嵌入或提供该键名。 | -| `std::path::Path::new("/absolute/cover.png")` | 读取文件系统路径 | 随应用安装文件,或由用户选择文件。 | -| `"https://example.com/cover.png"` | 通过配置的 HTTP 客户端请求 URL | 处理网络连接、加载中与 HTTP 错误。 | -| `Arc` | 解码调用方提供的编码字节和格式 | 提供字节与格式相符的 `Image`。 | -| `Arc` | 使用已经可供绘制的图片数据 | 调用方创建或持有可绘制数据。 | - -不是 URL 的**字符串**会作为资源键名处理,即使看起来像相对文件名,也不会按进程当前工作目录解析。要嵌入应用的 `images/cover.png`,将文件包含在应用自己的 `AssetSource` 中,再通过 `with_assets(...)` 注册。例如原生 `rust-embed` 的根目录是 `./assets` 时,文件 `assets/images/cover.png` 对应的键名是 `images/cover.png`。资源源的实现和默认组件图标的回退方式见[应用资源示例](../docs/assets.md)。 +`with_assets(Assets)` 注册默认组件图标;要嵌入自己的图片,请注册自己的 `AssetSource`(见[图标与资源](../docs/assets.md))。`"images/cover.png"` 这样的字符串是资源键,`Path` 读取文件,URL 则通过 App 的 `HttpClient` 下载;见[图片来源](../docs/image.md#图片来源)。 -## 尺寸与填充方式 +## 常用写法 -为图片设定合适的布局尺寸。`object_fit` 决定内容如何放进这块区域;默认值是 `Contain`。 +填满固定区域并裁掉边缘的缩略图: ```rust img("images/cover.png") @@ -70,56 +60,27 @@ img("images/cover.png") .object_fit(ObjectFit::Cover) ``` -| 模式 | 效果 | -| --- | --- | -| `Contain` | 保持比例,完整显示图片;可能留下空白。 | -| `Cover` | 保持比例并填满区域;边缘可能被裁掉。 | -| `Fill` | 拉伸到指定宽高;可能变形。 | -| `ScaleDown` | 类似 `Contain`,但不放大原图。 | -| `None` | 保持图片原始尺寸。 | - -可能裁剪的缩略图通常用 `Cover`;需要完整显示的 Logo 和图示通常用 `Contain`。如果加载期间周围布局必须稳定,请同时指定宽和高。 - -### 响应式宽度与稳定比例 - -让父容器决定可用宽度,限制宽窗口中的图片最大尺寸,并在图片字节到达之前预留高度: +跟随父元素宽度、在图片加载前就占好高度的横幅: ```rust -div() +img("images/banner.webp") .w_full() - .max_w(px(640.)) - .child( - img("images/banner.webp") - .w_full() - .aspect_ratio(16. / 9.) - .object_fit(ObjectFit::Cover), - ) + .aspect_ratio(16. / 9.) + .object_fit(ObjectFit::Cover) ``` -`w_full()` 跟随父容器的宽度;`max_w(...)` 限制整个图片区域在宽窗口中的尺寸。显式设置 `aspect_ratio(...)`,可以在解码前预留稳定的空间。正方形卡片可使用 `.aspect_ratio(1.)`。如果没有显式比例或高度,GPUI 可以在解码后使用图片的固有比例,但布局可能在加载完成时改变。如果 flex 子元素在窄窗口中无法收缩,可为其所在容器设置 `.min_w_0()`。 - -### 小型图片网格 - -下面是视图 `render` 方法中的局部示例,假设已经注册包含三个键名的 `AssetSource`。示例固定为三列;当窗口变窄时,应用应在自己的布局断点更改列数或布局方式。 +带加载中和加载失败状态的远程图片。必须设置 `.id(...)`,加载中的内容才会出现: ```rust -div() - .grid() - .grid_cols(3) - .gap_3() - .children([ - "images/one.webp", - "images/two.webp", - "images/three.webp", - ].into_iter().map(|source| { - img(source) - .w_full() - .aspect_ratio(1.) - .object_fit(ObjectFit::Cover) - })) +img("https://example.com/avatar.png") + .id("avatar") + .size(px(48.)) + .rounded_full() + .with_loading(|| div().child("Loading image...").into_any_element()) + .with_fallback(|| div().child("Image unavailable").into_any_element()) ``` -每个图片格在加载中都会保留相同的正方形区域。`Cover` 可能裁掉重要内容,因此图表或需要显示完整边缘的产品图片应使用 `Contain`。对于长而持续变化的集合,使用滚动或虚拟列表,而不是一次构建所有图片格。 +必须完整显示的 Logo 和图表,使用 `ObjectFit::Contain`(默认值)。要保留多色 SVG 的颜色,用 `img()` 绘制;跟随主题变色的图标,使用 [Icon](./icon.md)。见[尺寸与适配](../docs/image.md#尺寸与适配)和[选择 img() 还是 svg()](../docs/image.md#选择-img-还是-svg)。 ## 可选择图片的画廊 @@ -193,338 +154,14 @@ fn main() { 被选中的按钮有明确的选中状态,可见的 `Selected: ...` 文本也报告当前选择。在实际画廊中,使用 `Show front view` 等有意义的名称,不要只用文件名。如果图片集合可能为空,先显示空状态,再读取选中项。如果图片来源会被删除或重新排序,应保存稳定的业务 ID,而不是数组索引,并在渲染时解析它。 -## 叠字主图 - -把文字放在图片上方的独立表面,让可读性不依赖照片本身的像素。下面的局部示例假设 `cx` 是视图上下文,并已导入 `ActiveTheme`。 - -```rust -use gpui_kit::component::ActiveTheme as _; - -div() - .relative() - .w_full() - .max_w(px(720.)) - .h(px(320.)) - .overflow_hidden() - .child( - img("images/hero.webp") - .absolute() - .inset_0() - .size_full() - .object_fit(ObjectFit::Cover), - ) - .child( - div() - .absolute() - .bottom_0() - .left_0() - .p_4() - .bg(cx.theme().background) - .child("Explore the collection"), - ) -``` - -这里的图片只作装饰,文字负责传达信息。任何操作控件都应避开被裁切或遮挡的区域,让键盘焦点始终可见。 - -## 加载中与加载失败 - -`img()` 通过 `StyledImage` 提供真正的加载态与错误回退 API。回调返回用于替代图片显示的元素。下例使用嵌入资源键名;URL 和文件系统路径也适用。 - -```rust -img("images/cover.png") - .id("cover-image") - .w(px(320.)) - .h(px(180.)) - .object_fit(ObjectFit::Cover) - .with_loading(|| div().child("Loading image...").into_any_element()) - .with_fallback(|| div().child("Image unavailable").into_any_element()) -``` - -加载很快时,加载提示可能来不及出现。资源键名不存在、本地文件缺失、图片字节无法解码或 HTTP 请求失败,都可能触发回退内容。为外层布局保留固定尺寸,避免替代内容出现时推动周边界面。GPUI 会缓存加载的图片资源;只有需要特定缓存生命周期时才需要自定义图片缓存。 - -`with_loading` 和 `with_fallback` 只负责提供替代元素;它们不会暴露独立的 `ImageState` 枚举,也不会自动添加重试命令。如果远程图片是任务必需内容,应在应用状态中提供相邻的重试操作,并在用户重试时使用更新后的来源重新构建图片。来源已经失败时,不要继续显示假装正在加载的骨架屏。 - -## 远程图片的 HTTP 缓存 - -`img("https://...")` 这类 URL 来源,以及 `TextView` 文档中的远程图片,都由 GPUI 通过安装在 `App` 上的 `HttpClient` 加载。在原生平台上,这个客户端由应用决定:默认客户端会让所有请求失败,因此要先用 `cx.set_http_client(...)` 或 `Application::with_http_client(...)` 安装一个客户端,远程图片才能加载。在 Web 上,`gpui_kit::application()` 会安装基于浏览器 Fetch API 的客户端,浏览器自身的 HTTP 缓存已经生效;本节只讨论原生应用。 - -GPUI 的图片缓存位于这个客户端之上。它在内存中按来源保存解码后的图片,足以应付运行中视图里的重复来源,却无法减少网络请求: - -- 进程退出后缓存随之消失,每次启动都要重新下载所有图片。 -- 它不理会 `Cache-Control`、`ETag` 和 `Last-Modified`,既不能保留服务器允许复用的响应,也不能向服务器确认旧副本是否仍然有效。 -- 它的寿命取决于持有它的缓存。使用独立 `.image_cache(...)` 的图片,或按视图分别缓存的加载器,每创建一个新视图都会重新请求图片。 - -要跨视图、跨启动复用响应,可以用一个遵循 HTTP 缓存规则的客户端包装应用原有的 `HttpClient`,并在启动时安装一次。此后所有远程图片都会受益,包括不由应用自己编写的代码加载的图片。实现时遵循以下规则: - -- **只缓存不带请求体的 GET。** 其他请求原样转发。 -- **按共享缓存处理。** 整个应用共用一个客户端:应用自己的视图、扩展和文档视图都经过它。`no-store` 或 `private` 响应,以及对带 `Authorization` 请求的响应,除非服务器明确允许共享缓存保存,否则都不能存储。如果缓存下层会附加 Cookie 或令牌,缓存看不到这些凭据,因此应在缓存之上添加凭据,或者不缓存这些主机。 -- **用重新验证代替重新下载。** 新鲜的响应直接返回,不访问网络。过期后发送 `If-None-Match` 或 `If-Modified-Since`;收到 `304 Not Modified` 时,用更新后的响应头返回已存储的响应体。 -- **遵守调用方的重定向策略。** `img()` 会跟随重定向,但自行授权每一跳的加载器会请求 `RedirectPolicy::NoFollow`,并且必须拿到 `3xx` 响应。把策略放进缓存键,跟随重定向得到的结果就永远不会回应这类调用方;重定向响应本身也按同样的规则缓存。 -- **限制内存和磁盘用量。** 同时限制单个条目和总大小,淘汰旧条目;过大而不宜保存的响应直接以流的形式转发。 -- **先授权,再请求。** 同一个 URL,缓存会回应任何请求它的调用方。判断调用方能否访问某个 URL 的检查,例如 gpui-shell 脚本的网络授权,必须在 `send` 之前完成。这样缓存不会扩大调用方的访问范围,只是省去重复一次已经获准的请求。 - -下面的示例在内存中实现这些规则。[http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate 负责 HTTP 缓存规则的判断:新鲜度、验证器、`Vary` 以及共享缓存的限制。在 `Cargo.toml` 中加入 `http-cache-semantics = "2"`、`futures`、`bytes` 和 `anyhow`。 - -```rust -use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex}, - time::SystemTime, -}; - -use bytes::Bytes; -use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; -use gpui_kit::http_client::{ - AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, - http::{HeaderValue, response}, -}; -use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; - -/// An [`HttpClient`] that answers GET requests from memory when HTTP caching -/// rules allow it, and forwards everything else to `inner`. -pub struct CachingHttpClient { - inner: Arc, - store: Arc>, -} - -impl CachingHttpClient { - pub fn new(inner: Arc, max_bytes: usize) -> Self { - let store = Store { - max_bytes, - ..Default::default() - }; - Self { - inner, - store: Arc::new(Mutex::new(store)), - } - } -} - -/// The URI plus the caller's redirect policy. A caller that disables -/// redirects must receive the 3xx itself, never a followed result. -type Key = (String, Option); - -struct Entry { - policy: CachePolicy, - body: Bytes, -} - -#[derive(Default)] -struct Store { - entries: HashMap, - /// Keys in insertion order; the oldest is evicted first. - order: VecDeque, - bytes: usize, - max_bytes: usize, -} - -impl Store { - /// A single response may use at most an eighth of the budget. - fn max_entry_bytes(&self) -> usize { - self.max_bytes / 8 - } - - fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { - let entry = self.entries.get(key)?; - Some((entry.policy.clone(), entry.body.clone())) - } - - fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { - self.remove(&key); - self.bytes += body.len(); - self.order.push_back(key.clone()); - self.entries.insert(key, Entry { policy, body }); - while self.bytes > self.max_bytes { - let Some(oldest) = self.order.pop_front() else { - break; - }; - if let Some(entry) = self.entries.remove(&oldest) { - self.bytes -= entry.body.len(); - } - } - } - - fn remove(&mut self, key: &Key) { - if let Some(entry) = self.entries.remove(key) { - self.bytes -= entry.body.len(); - self.order.retain(|k| k != key); - } - } -} - -fn respond(head: response::Parts, body: Bytes) -> Response { - Response::from_parts(head, AsyncBody::from_bytes(body)) -} - -impl HttpClient for CachingHttpClient { - fn user_agent(&self) -> Option<&HeaderValue> { - self.inner.user_agent() - } - - fn proxy(&self) -> Option<&Url> { - self.inner.proxy() - } - - fn send( - &self, - req: Request, - ) -> BoxFuture<'static, anyhow::Result>> { - // Only a GET without a body is cacheable; pass everything else through. - if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { - return self.inner.send(req); - } - - let inner = self.inner.clone(); - let store = self.store.clone(); - async move { - let (request, _) = req.into_parts(); - let redirects = request.extensions.get::().cloned(); - let key = (request.uri.to_string(), redirects); - let cached = store.lock().unwrap().get(&key); - - // Fresh: answer without the network. Stale: send the conditional - // headers (If-None-Match / If-Modified-Since) the policy computed, - // keeping the caller's extensions such as its redirect policy. - let mut outgoing = request.clone(); - if let Some((policy, body)) = &cached { - match policy.before_request(&request, SystemTime::now()) { - BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), - BeforeRequest::Stale { - request: revalidation, - .. - } => { - outgoing.headers = revalidation.headers; - } - } - } - - let (head, body) = inner - .send(Request::from_parts(outgoing, AsyncBody::empty())) - .await? - .into_parts(); - - // 304: keep the cached body under the refreshed headers. - if let Some((policy, cached_body)) = cached { - match policy.after_response(&request, &head, SystemTime::now()) { - AfterResponse::NotModified(policy, head) => { - let mut store = store.lock().unwrap(); - store.insert(key, policy, cached_body.clone()); - return Ok(respond(head, cached_body)); - } - AfterResponse::Modified(..) => {} - } - } - - // `CachePolicy::new` evaluates the response as a shared cache: - // `no-store` and `private` responses, and most responses to requests - // carrying `Authorization`, are not storable. - let policy = CachePolicy::new(&request, &head); - if !policy.is_storable() { - store.lock().unwrap().remove(&key); - return Ok(Response::from_parts(head, body)); - } - - let limit = store.lock().unwrap().max_entry_bytes(); - let mut bytes = Vec::new(); - let mut reader = body.take(limit as u64 + 1); - reader.read_to_end(&mut bytes).await?; - let body = reader.into_inner(); - if bytes.len() > limit { - // Too large to keep: return what was read, then the rest of the stream. - store.lock().unwrap().remove(&key); - let rest = Cursor::new(bytes).chain(body); - return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); - } - - let bytes = Bytes::from(bytes); - store.lock().unwrap().insert(key, policy, bytes.clone()); - Ok(respond(head, bytes)) - } - .boxed() - } -} -``` - -在应用原有的客户端外层安装这个包装。这里的 `reqwest_client` 是 `gpui-pre-reqwest-client` crate,版本与所用 `gpui-kit` 固定的 GPUI 快照一致: - -```rust -use std::sync::Arc; - -gpui_kit::application().run(|cx| { - gpui_kit::init(cx); - - let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") - .expect("failed to create the HTTP client"); - let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); - cx.set_http_client(Arc::new(cached)); - - // Open windows here. -}); -``` - -这个示例刻意保持简短,应用有需要时可以在以下方面扩展: - -- **持久化。** 应用退出后条目随之消失。要跨启动保留图片,可以把每个响应体连同其 `CachePolicy` 存到磁盘(`CachePolicy` 实现了 `serde` 的 trait),以原子方式写入文件,并在启动时清理超出大小或时间限制的条目。基于 `reqwest` 的客户端也可以改用带磁盘存储的缓存中间件,例如 [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest)。 -- **变体。** 每个 URL 只保留一个响应。因 `Vary` 而不同的响应会替换之前的变体。 -- **重复请求。** 同一 URL 的并发未命中会各自访问网络。GPUI 的图片加载器本身已经让同一来源只加载一次。 -- **淘汰策略。** 总是先移除最早存入的条目,不管它最近是否被使用。如果访问模式很重要,可以改用 LRU 结构。 - -## SVG 图片与单色图标 - -两种形式都能从同一 `AssetSource` 读取键名,但渲染方式不同: - -| API | 渲染方式 | 适用场景 | -| --- | --- | --- | -| `img("images/brand.svg")` | 将 SVG 光栅化为图片,保留文件原有颜色;支持 `object_fit`。 | 多色 Logo、插画和图示。 | -| `svg().path("icons/check.svg")` | 使用 SVG 的透明度蒙版,按元素文字颜色绘制成单色。 | 跟随主题或状态变化的单色图标。 | - -```rust -img("images/brand.svg") - .size(px(96.)) - .object_fit(ObjectFit::Contain); - -svg().path("icons/check.svg") - .size(px(20.)) - .text_color(rgb(0x2563eb)); -``` - -给 `img("images/brand.svg")` 加 `.text_color(...)` **不会**改变图片颜色。单色图标应使用 `svg().path(...)` 或 GPUI Kit 的 [`Icon`](./icon.md)。少量自定义 SVG 也可以用 `svg().data(include_bytes!("check.svg"))` 直接提供字节,省去资源键名查找。全彩 SVG 仍应通过 `img()` 显示。 - -`img()` 支持 PNG、JPEG、WebP、GIF 等常见位图格式,也支持 SVG。具体格式能力来自当前固定版本的 GPUI 图片解码器;发布前应在目标平台用实际文件检查效果。 - -## API 速查 - -| API | 用途 | 注意 | -| --- | --- | --- | -| `img(source)` | 根据 `ImageSource` 创建图片 | 非 URL 字符串是嵌入资源键名。 | -| `.id(id)` | 提供稳定的元素标识 | 有助于跨帧保存图片加载状态。 | -| `.w(...)`、`.h(...)`、`.size(...)` | 指定明确的尺寸 | 在远程图片加载前预留空间。 | -| `.w_full()`、`.h_full()`、`.size_full()` | 填满父容器相应方向 | 父容器仍需有明确的可用尺寸。 | -| `.max_w(...)`、`.aspect_ratio(...)` | 限制宽度并预留比例 | 适用于响应式布局。 | -| `.object_fit(ObjectFit::...)` | 选择裁切与缩放方式 | 默认是 `Contain`。 | -| `.with_loading(...)`、`.with_fallback(...)` | 提供替代元素 | 每个回调都返回 `AnyElement`。 | -| `.grayscale(true)` | 用灰度显示图片 | 视觉效果不会改变图片来源。 | -| `.image_cache(&cache)` | 覆盖此图片使用的缓存 | 仅在确实需要特定缓存生命周期时使用。 | - -`rounded(...)`、边框、`overflow_hidden()` 和阴影属于普通元素或容器的样式,不是图片解码选项。需要圆角裁切时,将图片放进带圆角的父容器并裁切溢出内容。 - -## 性能与可访问性 - -- 图片尺寸应接近实际显示尺寸。很小的缩略图不需要全屏照片那么多编码像素。 -- 压缩源文件,并在目标显示设备上检查效果。照片和线条插画应选择合适的格式;不要假设所有平台或解码器支持所有格式。 -- 为稍后才到达的图片保留固定区域或稳定比例。加载态和失败态应占据同一块区域。 -- GPUI 默认的图片缓存已经处理普通的重复来源。只有测得明确的生命周期或内存需求时才引入自定义缓存。要避免新视图或重启后重新下载远程图片,应在 HTTP 层缓存,见[远程图片的 HTTP 缓存](#远程图片的-http-缓存)。 -- 对于大型滚动画廊,用集合 API 只构建可见或邻近的条目。单独调用 `img()` 并不等于实施了懒加载策略。 -- 对传达信息的图片提供可见说明或周围界面中的可访问描述。装饰性图片不需重复朗读。由图片驱动的操作应使用带名称、焦点和键盘激活能力的真实控件。 +## 可访问性 -## 排查问题 +- 传达信息的图片,要在周围界面配上可见文字或无障碍描述。文件名不是描述。装饰性图片不需要朗读。 +- 点击后会执行命令的图片,要做成真正的控件,例如 `Button`,这样它才有名称、焦点和键盘操作。 +- 加载中和加载失败的内容要占用与图片相同的区域,避免推动周围内容。 -| 现象 | 检查方向 | -| --- | --- | -| 嵌入图片空白 | 确认已注册的 `AssetSource` 包含完全相同的键名和文件字节。`img("images/cover.png")` 是资源查找,不会读取当前目录的文件。 | -| 默认图标缺失 | 注册 GPUI Kit 默认的 `Assets`,或把它作为应用资源源的回退来源。 | -| 多色 SVG 变成单色 | 使用 `img()` 显示;`svg().path(...)` 会有意使用透明度蒙版。 | -| 图片被裁切 | 改用 `ObjectFit::Contain`,或增加显示区域的尺寸。 | -| 出现错误回退内容 | 按所选来源检查文件路径、网络响应或图片字节能否解码。 | +## 延伸阅读 -如果图片承载信息,应在周边界面提供文字说明或其他可访问描述;文件名不能代替面向用户的描述。 +- [图片](../docs/image.md):`img()` 的所有来源、加载与解码、`svg()` 和常见问题。 +- [解码后图片的缓存](../docs/image.md#解码后图片的缓存):限定范围的缓存与自定义 `ImageCache`。 +- [远程图片的 HTTP 缓存](../docs/image.md#远程图片的-http-缓存):跨视图、跨启动复用下载结果。 diff --git a/website/zh-CN/docs/assets.md b/website/zh-CN/docs/assets.md index 2f28ec2603..c0049b2cab 100644 --- a/website/zh-CN/docs/assets.md +++ b/website/zh-CN/docs/assets.md @@ -281,7 +281,7 @@ let color_artwork = img("images/illustration.svg").size(px(160.)); `img()` 支持常见的 PNG、JPEG、WebP、GIF、SVG,以及 GPUI 的 `Img::extensions()` 列出的其他格式。它通过字节识别位图格式,SVG 则走另一条解码路径;因此文件内容也必须有效,不能只改扩展名。默认的 `ObjectFit::Contain` 会在边界内完整显示图片;`Cover` 会铺满并可能裁剪,`Fill` 可能拉伸变形。`object_fit` 作用于 `img()`,不作用于单色 `svg()` 元素。`img()` 通过 GPUI 的图片缓存异步加载并解码;嵌入资源的字节会复制进加载器,嵌入本身不会省掉解码或运行时图片内存。动画 GIF 和 WebP 可以包含多个帧。 -对于 `"images/cover.png"` 这样的字符串,GPUI 会用该键调用已注册的 `AssetSource`。`img(std::path::Path::new("/absolute/file.png"))` 读取文件系统路径,URL 字符串使用 HTTP 加载器;它们的部署方式和错误处理不同。更多尺寸与填充示例见[图片](../component/image.md)。 +对于 `"images/cover.png"` 这样的字符串,GPUI 会用该键调用已注册的 `AssetSource`。`img(std::path::Path::new("/absolute/file.png"))` 读取文件系统路径,URL 字符串使用 HTTP 加载器;它们的部署方式和错误处理不同。各种来源如何加载、布局和缓存,见[图片](./image.md)。 ## 单独嵌入 SVG 图标 diff --git a/website/zh-CN/docs/image.md b/website/zh-CN/docs/image.md new file mode 100644 index 0000000000..3ea9ac0a0f --- /dev/null +++ b/website/zh-CN/docs/image.md @@ -0,0 +1,431 @@ +--- +title: 图片 +description: img() 与 svg() 如何加载、解码、布局和缓存图片,以及如何在 HTTP 层缓存远程图片。 +order: -6.9 +--- + +# 图片 + +GPUI 用两个元素绘制图片。`img()` 绘制全彩图片:照片、截图、头像或多色 SVG。`svg()` 把 SVG 绘制成单色图形,用文字颜色填充,图标就是这样跟随主题变色的。两者都从 `gpui_kit` 重新导出。本页说明这两个元素如何查找、解码、布局和缓存图片,以及如何避免重复下载远程图片。现成的界面写法见 [Image](../component/image.md) 组件页;把文件打包进程序见[图标与资源](./assets.md)。 + +## 图片来源 + +`img(source)` 接受任何能转换为 `ImageSource` 的值,转换结果决定从哪里读取字节: + +| 参数 | `ImageSource` | 字节来源 | +| --- | --- | --- | +| `"https://example.com/a.png"` 或任何能解析为 URL 的字符串;`SharedUri` | `Resource(Resource::Uri)` | `App` 上安装的 `HttpClient` | +| `"images/a.png"` 或其他字符串 | `Resource(Resource::Embedded)` | 已注册的 `AssetSource`,按这个键精确查找 | +| `&Path`、`PathBuf`、`Arc` | `Resource(Resource::Path)` | 文件系统 | +| `Arc` | `Image` | 已持有的编码字节及其 `ImageFormat` | +| `Arc` | `Render` | 已解码的帧,直接绘制 | +| `Fn(&mut Window, &mut App) -> Option, ImageCacheError>>` | `Custom` | 自己的加载器 | + +字符串只要能解析为 URL 就按 URL 处理,否则就是资源键。`"images/a.png"` 这样看起来像相对路径的字符串,不会从工作目录读取。磁盘上的文件请传 `Path`,这样既不会被当成 URL,也不会被当成资源键。 + +在原生平台上,默认的 `HttpClient` 会让所有请求失败,所以应用要先用 `cx.set_http_client(...)` 或 `Application::with_http_client(...)` 安装客户端,URL 图片才能加载。在 Web 上,`gpui_kit::application()` 会安装基于浏览器 Fetch API 的客户端。 + +`Custom` 加载器在每一帧的布局和绘制阶段都会运行。图片还在加载时返回 `None`,结果要自己保存,例如用 `window.use_asset::(...)`,否则每一帧都会重新开始加载。 + +## 加载、解码与失败 + +加载是异步的。加载期间,`img()` 按自身样式布局,但不绘制任何内容。加载完成后,GPUI 会重绘绘制这张图片的视图。 + +```rust +img("https://example.com/cover.png") + .id("cover") + .w(px(320.)) + .h(px(180.)) + .with_loading(|| div().child("Loading image...").into_any_element()) + .with_fallback(|| div().child("Image unavailable").into_any_element()) +``` + +- `with_loading` 只在加载开始 200 ms 后仍未完成时才替换图片,所以加载很快时不会闪出占位内容。GPUI 在元素状态里记录这个时间,因此只有带 `.id(...)` 的图片才会显示占位内容。 +- 加载失败时,`with_fallback` 会替换图片。失败的情况包括:资源键或文件不存在、网络错误、响应不是 `2xx`、字节无法解码。 +- 两个回调都不会重试。要重试,在视图状态里记录重试,再换一个来源重新绘制图片,或者先删掉缓存的失败结果(见[解码后图片的缓存](#解码后图片的缓存))。 + +GPUI 根据字节内容判断格式,而不是根据文件扩展名。PNG、JPEG、WebP、GIF、BMP、TIFF、ICO 以及 `Img::extensions()` 列出的其他格式,都通过 `image` crate 解码。不属于已知位图格式的字节会按 SVG 解析,并按其固有尺寸的两倍光栅化一次。所以用 `img()` 显示的 SVG 在正常缩放下是清晰的,但放大到远超自身尺寸时会变模糊。 + +GIF 和 WebP 动图只有在图片带 `.id(...)` 时才会播放,因为当前帧保存在元素状态里。窗口处于活动状态时动图才会播放,系统要求减少动态效果时则停止。 + +## 尺寸与适配 + +布局决定图片的边界,`object_fit` 决定图片在边界内如何绘制。 + +某个维度为 `auto` 时,GPUI 会在图片解码后补上它。如果另一个维度是绝对长度,就按图片比例算出这个维度;否则使用图片自身的尺寸。除非另外设置,图片比例也会成为元素的 `aspect_ratio`。解码完成前没有尺寸可以测量,所以没有设置尺寸的图片在加载完成时会推动周围的布局。可以用明确的尺寸,或者宽度加 `aspect_ratio(...)`,预先占好位置: + +```rust +img("images/banner.webp") + .w_full() + .aspect_ratio(16. / 9.) + .object_fit(ObjectFit::Cover) +``` + +| `ObjectFit` | 效果 | +| --- | --- | +| `Contain`(默认) | 显示完整图片,保持宽高比;可能留有空白。 | +| `Cover` | 填满边界,保持宽高比;边缘可能被裁掉。 | +| `Fill` | 拉伸到边界大小;可能变形。 | +| `ScaleDown` | 与 `Contain` 相同,但不会放大图片。 | +| `None` | 按图片自身尺寸居中显示。 | + +在 `img()` 上设置 `.rounded(...)` 会让绘制出的图片本身带圆角。`.grayscale(true)` 以灰度绘制。边框、阴影和背景都是普通的元素样式。 + +## svg() + +`svg()` 把 SVG 绘制成单色图形。GPUI 按元素尺寸光栅化 SVG 的透明度通道,再用一种颜色填充,所以 SVG 自带的颜色会被丢弃。用以下三个方法之一指定来源: + +| 方法 | 字节来源 | +| --- | --- | +| `.path("icons/check.svg")` | 已注册的 `AssetSource`,按键查找 | +| `.external_path("/path/to/check.svg")` | 文件系统,异步读取并按路径缓存 | +| `.data(bytes)` | 传入的字节,按字节的哈希缓存 | + +```rust +svg() + .path("icons/check.svg") + .size(px(16.)) + .text_color(cx.theme().foreground) +``` + +- **在元素本身上设置颜色。** 只有元素自己设置了文字颜色,`svg()` 才会绘制。从父元素继承的颜色不算,所以没有 `.text_color(...)` 的 `svg()` 什么也不画。 +- **设置尺寸。** 布局阶段不会读取 SVG,所以 `svg()` 没有固有尺寸,不设尺寸就会缩成零。 +- **变换只影响绘制。** `.with_transformation(Transformation::rotate(percentage(0.25)))` 以及 `scale`、`translate` 只移动绘制结果,布局和点击区域保持不变。 + +在 GPUI Kit 中,图标优先使用 [Icon](../component/icon.md) 组件:它按组件尺寸体系确定大小,并从主题取颜色。 + +## 选择 img() 还是 svg() + +| | `img()` | `svg()` | +| --- | --- | --- | +| 颜色 | 保留原有颜色 | 单色,来自 `.text_color(...)` | +| 格式 | 位图格式和 SVG | 只支持 SVG | +| 尺寸 | 使用图片的固有尺寸 | 必须设置 | +| 来源 | URL、资源键、路径、字节、已解码的帧、自定义加载器 | 资源键、路径、字节 | +| 加载与失败 | `with_loading`、`with_fallback` | 准备好之前或失败时不绘制 | +| 适用于 | 照片、头像、Logo、插画 | 跟随主题或状态变色的图标和符号 | + +`.text_color(...)` 不能给 `img()` 改色,`svg()` 也无法保留多色 Logo 的颜色。 + +## 解码后图片的缓存 + +解码后的图片会被保留,再次绘制同一来源时不必重新加载。由哪个缓存保留,决定了图片何时被释放。 + +- **默认缓存。** `Resource` 来源(URL、资源键或路径)的 `img()` 使用 App 的资源缓存,按来源作为键。一个条目供所有窗口和视图共用,直到用 `ImageSource::remove_asset(cx)` 删除为止。加载失败的结果也会被缓存,所以重试同一来源前要先删除这个条目。`Arc` 也以同样的方式缓存。 +- **限定范围的缓存。** `image_cache(provider)` 包裹子元素,其中的 `img()` 改用这个缓存。`image_cache(retain_all("preview"))` 在元素状态里保存一个 `RetainAllImageCache`:它保留加载过的所有图片,元素不再绘制时一并释放。`img(...).image_cache(&cache)` 为单张图片指定缓存。 +- **自定义缓存。** 实现 `ImageCache` 来决定保留哪些图片。`ImageCacheItem::new(resource, cx)` 通过 GPUI 的图片加载器开始加载,`item.use_image(window)` 返回结果,并在加载完成时重绘当前视图。淘汰图片时,用 `cx.drop_image(image, Some(window))` 释放它的 GPU 纹理。 + +下面的缓存保留最近绘制过的图片,释放其余的。容量必须大于同时显示在屏幕上的图片数量,否则可见的图片会被淘汰,每一帧都要重新加载。 + +```rust +use std::{collections::VecDeque, sync::Arc}; + +use gpui_kit::*; + +/// Keeps the `capacity` most recently drawn images and releases the rest. +pub struct RecentImageCache { + capacity: usize, + items: VecDeque<(Resource, ImageCacheItem)>, +} + +impl RecentImageCache { + pub fn new(capacity: usize, cx: &mut App) -> Entity { + let cache = cx.new(|_| Self { + capacity, + items: VecDeque::new(), + }); + // Release the GPU textures when the cache itself is dropped. + cx.observe_release(&cache, |cache, cx| { + for (_, item) in cache.items.drain(..) { + if let Some(Ok(image)) = item.get() { + cx.drop_image(image, None); + } + } + }) + .detach(); + cache + } +} + +impl ImageCache for RecentImageCache { + fn load( + &mut self, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + let item = match self.items.iter().position(|(source, _)| source == resource) { + Some(ix) => self.items.remove(ix).expect("index is in bounds"), + None => (resource.clone(), ImageCacheItem::new(resource, cx)), + }; + self.items.push_front(item); + while self.items.len() > self.capacity { + if let Some((_, evicted)) = self.items.pop_back() + && let Some(Ok(image)) = evicted.get() + { + cx.drop_image(image, Some(window)); + } + } + self.items[0].1.use_image(window) + } +} +``` + +创建一次,把 `Entity` 保存在视图里,再用它包裹需要使用这个缓存的图片: + +```rust +image_cache(self.images.clone()) + .flex() + .gap_2() + .children(self.urls.iter().map(|url| img(url.clone()).size(px(96.)))) +``` + +这些缓存都在内存中保存解码后的图片。它们仍然通过 App 的 `HttpClient` 加载 URL,也都不会记住服务器对响应的缓存要求,这正是下一节要解决的问题。 + +## 远程图片的 HTTP 缓存 + +前面几节讲的是 `img()` 在内存中保存什么。本节讲网络层:在原生平台上如何跨视图、跨启动复用响应。在 Web 上,浏览器的 HTTP 缓存已经生效。 + +### GPUI 图片缓存的局限 + +GPUI 的图片缓存位于 App 的 `HttpClient` 之上。它在内存中按来源保存解码后的图片,足以应付运行中视图里的重复来源,却无法减少网络请求: + +- 进程退出后缓存随之消失,每次启动都要重新下载所有图片。 +- 它不理会 `Cache-Control`、`ETag` 和 `Last-Modified`,既不能保留服务器允许复用的响应,也不能向服务器确认旧副本是否仍然有效。 +- 它的寿命取决于持有它的缓存。使用独立 `.image_cache(...)` 的图片,或按视图分别缓存的加载器,每创建一个新视图都会重新请求图片。 + +### 在 HTTP 层缓存 + +要跨视图、跨启动复用响应,可以用一个遵循 HTTP 缓存规则的客户端包装应用原有的 `HttpClient`,并在启动时安装一次。此后所有远程图片都会受益,包括不由应用自己编写的代码加载的图片。实现时遵循以下规则: + +- **只缓存不带请求体的 GET。** 其他请求原样转发。 +- **按共享缓存处理。** 整个应用共用一个客户端:应用自己的视图、扩展和文档视图都经过它。`no-store` 或 `private` 响应,以及对带 `Authorization` 请求的响应,除非服务器明确允许共享缓存保存,否则都不能存储。如果缓存下层会附加 Cookie 或令牌,缓存看不到这些凭据,因此应在缓存之上添加凭据,或者不缓存这些主机。 +- **用重新验证代替重新下载。** 新鲜的响应直接返回,不访问网络。过期后发送 `If-None-Match` 或 `If-Modified-Since`;收到 `304 Not Modified` 时,用更新后的响应头返回已存储的响应体。 +- **遵守调用方的重定向策略。** `img()` 会跟随重定向,但自行授权每一跳的加载器会请求 `RedirectPolicy::NoFollow`,并且必须拿到 `3xx` 响应。把策略放进缓存键,跟随重定向得到的结果就永远不会回应这类调用方;重定向响应本身也按同样的规则缓存。 +- **限制内存和磁盘用量。** 同时限制单个条目和总大小,淘汰旧条目;过大而不宜保存的响应直接以流的形式转发。 +- **先授权,再请求。** 同一个 URL,缓存会回应任何请求它的调用方。判断调用方能否访问某个 URL 的检查,例如 gpui-shell 脚本的网络授权,必须在 `send` 之前完成。这样缓存不会扩大调用方的访问范围,只是省去重复一次已经获准的请求。 + +### 示例 + +下面的示例在内存中实现这些规则。[http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate 负责 HTTP 缓存规则的判断:新鲜度、验证器、`Vary` 以及共享缓存的限制。在 `Cargo.toml` 中加入 `http-cache-semantics = "2"`、`futures`、`bytes` 和 `anyhow`。 + +```rust +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use bytes::Bytes; +use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; +use gpui_kit::http_client::{ + AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, + http::{HeaderValue, response}, +}; +use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; + +/// An [`HttpClient`] that answers GET requests from memory when HTTP caching +/// rules allow it, and forwards everything else to `inner`. +pub struct CachingHttpClient { + inner: Arc, + store: Arc>, +} + +impl CachingHttpClient { + pub fn new(inner: Arc, max_bytes: usize) -> Self { + let store = Store { + max_bytes, + ..Default::default() + }; + Self { + inner, + store: Arc::new(Mutex::new(store)), + } + } +} + +/// The URI plus the caller's redirect policy. A caller that disables +/// redirects must receive the 3xx itself, never a followed result. +type Key = (String, Option); + +struct Entry { + policy: CachePolicy, + body: Bytes, +} + +#[derive(Default)] +struct Store { + entries: HashMap, + /// Keys in insertion order; the oldest is evicted first. + order: VecDeque, + bytes: usize, + max_bytes: usize, +} + +impl Store { + /// A single response may use at most an eighth of the budget. + fn max_entry_bytes(&self) -> usize { + self.max_bytes / 8 + } + + fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { + let entry = self.entries.get(key)?; + Some((entry.policy.clone(), entry.body.clone())) + } + + fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { + self.remove(&key); + self.bytes += body.len(); + self.order.push_back(key.clone()); + self.entries.insert(key, Entry { policy, body }); + while self.bytes > self.max_bytes { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(entry) = self.entries.remove(&oldest) { + self.bytes -= entry.body.len(); + } + } + } + + fn remove(&mut self, key: &Key) { + if let Some(entry) = self.entries.remove(key) { + self.bytes -= entry.body.len(); + self.order.retain(|k| k != key); + } + } +} + +fn respond(head: response::Parts, body: Bytes) -> Response { + Response::from_parts(head, AsyncBody::from_bytes(body)) +} + +impl HttpClient for CachingHttpClient { + fn user_agent(&self) -> Option<&HeaderValue> { + self.inner.user_agent() + } + + fn proxy(&self) -> Option<&Url> { + self.inner.proxy() + } + + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + // Only a GET without a body is cacheable; pass everything else through. + if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { + return self.inner.send(req); + } + + let inner = self.inner.clone(); + let store = self.store.clone(); + async move { + let (request, _) = req.into_parts(); + let redirects = request.extensions.get::().cloned(); + let key = (request.uri.to_string(), redirects); + let cached = store.lock().unwrap().get(&key); + + // Fresh: answer without the network. Stale: send the conditional + // headers (If-None-Match / If-Modified-Since) the policy computed, + // keeping the caller's extensions such as its redirect policy. + let mut outgoing = request.clone(); + if let Some((policy, body)) = &cached { + match policy.before_request(&request, SystemTime::now()) { + BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), + BeforeRequest::Stale { + request: revalidation, + .. + } => { + outgoing.headers = revalidation.headers; + } + } + } + + let (head, body) = inner + .send(Request::from_parts(outgoing, AsyncBody::empty())) + .await? + .into_parts(); + + // 304: keep the cached body under the refreshed headers. + if let Some((policy, cached_body)) = cached { + match policy.after_response(&request, &head, SystemTime::now()) { + AfterResponse::NotModified(policy, head) => { + let mut store = store.lock().unwrap(); + store.insert(key, policy, cached_body.clone()); + return Ok(respond(head, cached_body)); + } + AfterResponse::Modified(..) => {} + } + } + + // `CachePolicy::new` evaluates the response as a shared cache: + // `no-store` and `private` responses, and most responses to requests + // carrying `Authorization`, are not storable. + let policy = CachePolicy::new(&request, &head); + if !policy.is_storable() { + store.lock().unwrap().remove(&key); + return Ok(Response::from_parts(head, body)); + } + + let limit = store.lock().unwrap().max_entry_bytes(); + let mut bytes = Vec::new(); + let mut reader = body.take(limit as u64 + 1); + reader.read_to_end(&mut bytes).await?; + let body = reader.into_inner(); + if bytes.len() > limit { + // Too large to keep: return what was read, then the rest of the stream. + store.lock().unwrap().remove(&key); + let rest = Cursor::new(bytes).chain(body); + return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); + } + + let bytes = Bytes::from(bytes); + store.lock().unwrap().insert(key, policy, bytes.clone()); + Ok(respond(head, bytes)) + } + .boxed() + } +} +``` + +### 安装客户端 + +在应用原有的客户端外层安装这个包装。这里的 `reqwest_client` 是 `gpui-pre-reqwest-client` crate,版本与所用 `gpui-kit` 固定的 GPUI 快照一致: + +```rust +use std::sync::Arc; + +gpui_kit::application().run(|cx| { + gpui_kit::init(cx); + + let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") + .expect("failed to create the HTTP client"); + let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); + cx.set_http_client(Arc::new(cached)); + + // Open windows here. +}); +``` + +### 扩展示例 + +这个示例刻意保持简短,应用有需要时可以在以下方面扩展: + +- **持久化。** 应用退出后条目随之消失。要跨启动保留图片,可以把每个响应体连同其 `CachePolicy` 存到磁盘(`CachePolicy` 实现了 `serde` 的 trait),以原子方式写入文件,并在启动时清理超出大小或时间限制的条目。基于 `reqwest` 的客户端也可以改用带磁盘存储的缓存中间件,例如 [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest)。 +- **变体。** 每个 URL 只保留一个响应。因 `Vary` 而不同的响应会替换之前的变体。 +- **重复请求。** 同一 URL 的并发未命中会各自访问网络。GPUI 的图片加载器本身已经让同一来源只加载一次。 +- **淘汰策略。** 总是先移除最早存入的条目,不管它最近是否被使用。如果访问模式很重要,可以改用 LRU 结构。 + +## 常见问题 + +| 现象 | 检查 | +| --- | --- | +| 内嵌图片显示为空白 | 已注册的 `AssetSource` 必须包含完全相同的键。`img("images/a.png")` 是资源查找,不是读取文件。 | +| 原生平台上所有 URL 图片都显示失败内容 | 安装一个 `HttpClient`;默认客户端会让所有请求失败。 | +| `with_loading` 从不出现,或 GIF 不播放 | 给 `img()` 加上 `.id(...)`。 | +| `svg()` 看不见 | 在 `svg()` 元素本身上设置 `.text_color(...)` 和尺寸。 | +| 多色 SVG 变成了单色 | 用 `img()` 绘制;`svg()` 总是单色。 | +| 图片加载完成时布局跳动 | 用尺寸,或宽度加 `aspect_ratio(...)` 预先占位。 | +| 修好的图片仍显示之前的失败内容 | 失败结果被缓存了;重新绘制前调用 `ImageSource::remove_asset(cx)`。 | +| 每个新视图或每次重启都重新下载图片 | 在 HTTP 层缓存,见[远程图片的 HTTP 缓存](#远程图片的-http-缓存)。 | From 55dc9b300157652a1d087e2d3db5a369fc882263 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 25 Sep 2026 23:47:33 +0800 Subject: [PATCH 3/3] docs: build the HTTP image cache example on http-cache-reqwest Replace the hand-written cache with http-cache-reqwest middleware and a small adapter to GPUI's HttpClient. Build one client that follows redirects and one that does not, each with its own cache, so a caller that disables redirects still receives the 3xx. Co-Authored-By: Claude Opus 5.5 (1M context) --- website/docs/image.md | 251 ++++++++++++------------------------ website/zh-CN/docs/image.md | 251 ++++++++++++------------------------ 2 files changed, 170 insertions(+), 332 deletions(-) diff --git a/website/docs/image.md b/website/docs/image.md index a04836ffab..e2adcc81f4 100644 --- a/website/docs/image.md +++ b/website/docs/image.md @@ -196,202 +196,125 @@ GPUI's image cache sits above the App's `HttpClient`. It keeps decoded images in ### Cache at the HTTP layer -To reuse responses across views and launches, wrap the application's `HttpClient` in a client that applies HTTP caching rules, and install the wrapper once at startup. Every remote image then benefits, including images loaded by code the application does not own. Follow these rules: +To reuse responses across views and launches, install an `HttpClient` that applies HTTP caching rules once at startup. Every remote image then benefits, including images loaded by code the application does not own. Follow these rules: - **Cache only a GET without a request body.** Pass every other request through unchanged. - **Treat the cache as shared.** One client serves the whole application: its own views, extensions, and document views. Do not store a `no-store` or `private` response, or a response to a request carrying `Authorization`, unless the server explicitly allows a shared cache to keep it. A layer below the cache that adds cookies or tokens hides them from the cache, so add credentials above the cache, or leave those hosts uncached. - **Revalidate instead of downloading again.** Serve a fresh response without the network. When it is stale, send `If-None-Match` or `If-Modified-Since`; on `304 Not Modified`, return the stored body with the refreshed headers. -- **Honor the caller's redirect policy.** `img()` follows redirects, but a loader that authorizes each hop itself requests `RedirectPolicy::NoFollow` and must receive the `3xx` response. Keep the policy in the cache key so a followed result never answers that caller, and cache the redirect response itself by the same rules. -- **Bound memory and disk use.** Cap each entry and the total size, evict old entries, and stream a response that is too large to keep straight through. +- **Honor the caller's redirect policy.** `img()` follows redirects, but a loader that authorizes each hop itself requests `RedirectPolicy::NoFollow` and must receive the `3xx` response. Keep a separate cache for each policy, so a followed result never answers that caller. +- **Bound memory and disk use.** Cap the cache's size and remove old entries. - **Authorize before the request.** The cache answers any caller that asks for the same URL. A check that decides whether a caller may reach a URL, such as the network grants of gpui-shell scripts, must run before `send`. The cache then never widens what a caller can reach; it only avoids repeating a request that was already allowed. ### Example -The example below applies these rules in memory. The [http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate implements the HTTP caching rules: freshness, validators, `Vary`, and the restrictions on a shared cache. Add `http-cache-semantics = "2"`, `futures`, `bytes`, and `anyhow` to `Cargo.toml`. +[http-cache-reqwest](https://crates.io/crates/http-cache-reqwest) implements these rules as [reqwest-middleware](https://crates.io/crates/reqwest-middleware): freshness, `ETag` and `Last-Modified` revalidation, and the restrictions on a shared cache, with a disk store. The application only adapts it to GPUI's `HttpClient`. Add these dependencies: + +```toml +[dependencies] +anyhow = "1" +futures = "0.3" +http-cache-reqwest = "0.15" +reqwest = { version = "0.12", features = ["stream"] } +reqwest-middleware = "0.4" +tokio = { version = "1", features = ["rt-multi-thread"] } +``` ```rust -use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex}, - time::SystemTime, -}; +use std::{path::Path, sync::LazyLock}; -use bytes::Bytes; -use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; +use futures::{AsyncReadExt as _, FutureExt as _, TryStreamExt as _, future::BoxFuture}; use gpui_kit::http_client::{ - AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, - http::{HeaderValue, response}, + AsyncBody, HttpClient, RedirectPolicy, Request, Response, Url, http::HeaderValue, }; -use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; +use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions}; +use reqwest::redirect; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; + +/// reqwest needs a tokio runtime; GPUI's executors are not one. +static RUNTIME: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("failed to start the HTTP runtime") +}); -/// An [`HttpClient`] that answers GET requests from memory when HTTP caching -/// rules allow it, and forwards everything else to `inner`. -pub struct CachingHttpClient { - inner: Arc, - store: Arc>, +/// A reqwest client with an HTTP cache on disk. +pub struct CachedHttpClient { + follow: ClientWithMiddleware, + no_follow: ClientWithMiddleware, } -impl CachingHttpClient { - pub fn new(inner: Arc, max_bytes: usize) -> Self { - let store = Store { - max_bytes, - ..Default::default() +impl CachedHttpClient { + pub fn new(cache_dir: &Path) -> anyhow::Result { + let client = |policy, dir| -> anyhow::Result<_> { + let client = reqwest::Client::builder().redirect(policy).build()?; + Ok(ClientBuilder::new(client) + .with(Cache(HttpCache { + mode: CacheMode::Default, + manager: CACacheManager { + path: cache_dir.join(dir), + }, + options: HttpCacheOptions::default(), + })) + .build()) }; - Self { - inner, - store: Arc::new(Mutex::new(store)), - } - } -} - -/// The URI plus the caller's redirect policy. A caller that disables -/// redirects must receive the 3xx itself, never a followed result. -type Key = (String, Option); - -struct Entry { - policy: CachePolicy, - body: Bytes, -} - -#[derive(Default)] -struct Store { - entries: HashMap, - /// Keys in insertion order; the oldest is evicted first. - order: VecDeque, - bytes: usize, - max_bytes: usize, -} - -impl Store { - /// A single response may use at most an eighth of the budget. - fn max_entry_bytes(&self) -> usize { - self.max_bytes / 8 - } - - fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { - let entry = self.entries.get(key)?; - Some((entry.policy.clone(), entry.body.clone())) - } - - fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { - self.remove(&key); - self.bytes += body.len(); - self.order.push_back(key.clone()); - self.entries.insert(key, Entry { policy, body }); - while self.bytes > self.max_bytes { - let Some(oldest) = self.order.pop_front() else { - break; - }; - if let Some(entry) = self.entries.remove(&oldest) { - self.bytes -= entry.body.len(); - } - } - } - - fn remove(&mut self, key: &Key) { - if let Some(entry) = self.entries.remove(key) { - self.bytes -= entry.body.len(); - self.order.retain(|k| k != key); - } + // A caller that disables redirects must receive the 3xx itself. It + // gets a client that never follows them, with a cache of its own. + Ok(Self { + follow: client(redirect::Policy::default(), "follow")?, + no_follow: client(redirect::Policy::none(), "no-follow")?, + }) } } -fn respond(head: response::Parts, body: Bytes) -> Response { - Response::from_parts(head, AsyncBody::from_bytes(body)) -} - -impl HttpClient for CachingHttpClient { +impl HttpClient for CachedHttpClient { fn user_agent(&self) -> Option<&HeaderValue> { - self.inner.user_agent() + None } fn proxy(&self) -> Option<&Url> { - self.inner.proxy() + None } fn send( &self, req: Request, ) -> BoxFuture<'static, anyhow::Result>> { - // Only a GET without a body is cacheable; pass everything else through. - if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { - return self.inner.send(req); - } - - let inner = self.inner.clone(); - let store = self.store.clone(); + let (head, mut body) = req.into_parts(); + let client = match head.extensions.get::() { + Some(RedirectPolicy::NoFollow) => self.no_follow.clone(), + _ => self.follow.clone(), + }; async move { - let (request, _) = req.into_parts(); - let redirects = request.extensions.get::().cloned(); - let key = (request.uri.to_string(), redirects); - let cached = store.lock().unwrap().get(&key); - - // Fresh: answer without the network. Stale: send the conditional - // headers (If-None-Match / If-Modified-Since) the policy computed, - // keeping the caller's extensions such as its redirect policy. - let mut outgoing = request.clone(); - if let Some((policy, body)) = &cached { - match policy.before_request(&request, SystemTime::now()) { - BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), - BeforeRequest::Stale { - request: revalidation, - .. - } => { - outgoing.headers = revalidation.headers; - } - } - } - - let (head, body) = inner - .send(Request::from_parts(outgoing, AsyncBody::empty())) - .await? - .into_parts(); - - // 304: keep the cached body under the refreshed headers. - if let Some((policy, cached_body)) = cached { - match policy.after_response(&request, &head, SystemTime::now()) { - AfterResponse::NotModified(policy, head) => { - let mut store = store.lock().unwrap(); - store.insert(key, policy, cached_body.clone()); - return Ok(respond(head, cached_body)); - } - AfterResponse::Modified(..) => {} - } - } - - // `CachePolicy::new` evaluates the response as a shared cache: - // `no-store` and `private` responses, and most responses to requests - // carrying `Authorization`, are not storable. - let policy = CachePolicy::new(&request, &head); - if !policy.is_storable() { - store.lock().unwrap().remove(&key); - return Ok(Response::from_parts(head, body)); - } - - let limit = store.lock().unwrap().max_entry_bytes(); let mut bytes = Vec::new(); - let mut reader = body.take(limit as u64 + 1); - reader.read_to_end(&mut bytes).await?; - let body = reader.into_inner(); - if bytes.len() > limit { - // Too large to keep: return what was read, then the rest of the stream. - store.lock().unwrap().remove(&key); - let rest = Cursor::new(bytes).chain(body); - return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); - } - - let bytes = Bytes::from(bytes); - store.lock().unwrap().insert(key, policy, bytes.clone()); - Ok(respond(head, bytes)) + body.read_to_end(&mut bytes).await?; + let request = client + .request(head.method, head.uri.to_string()) + .headers(head.headers) + .body(bytes); + let response = RUNTIME.spawn(request.send()).await??; + + let mut builder = Response::builder() + .status(response.status()) + .version(response.version()); + *builder.headers_mut().unwrap() = response.headers().clone(); + let body = response + .bytes_stream() + .map_err(std::io::Error::other) + .into_async_read(); + Ok(builder.body(AsyncBody::from_reader(body))?) } .boxed() } } ``` +GPUI's own reqwest client applies the caller's `RedirectPolicy` to each request, but a `reqwest-middleware` client fixes the policy when it is built. The example therefore builds two clients: one that follows redirects, for `img()`, and one that never does, for callers that request `RedirectPolicy::NoFollow`. Each has its own cache directory, because a cache in front of a following client stores the final response under the original URL and would answer the other caller with it. `RedirectPolicy::FollowLimit` uses reqwest's default limit of 10. + ### Install the client -Install the wrapper around the client the application already uses. Here `reqwest_client` is the `gpui-pre-reqwest-client` crate at the GPUI snapshot version your `gpui-kit` release pins: +Install the client once at startup, before any window loads a remote image. Use your platform's cache directory, for example from the [dirs](https://crates.io/crates/dirs) crate, instead of the temporary directory used here: ```rust use std::sync::Arc; @@ -399,10 +322,9 @@ use std::sync::Arc; gpui_kit::application().run(|cx| { gpui_kit::init(cx); - let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") - .expect("failed to create the HTTP client"); - let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); - cx.set_http_client(Arc::new(cached)); + let cache_dir = std::env::temp_dir().join("my-app/http-cache"); + let client = CachedHttpClient::new(&cache_dir).expect("failed to create the HTTP client"); + cx.set_http_client(Arc::new(client)); // Open windows here. }); @@ -410,12 +332,9 @@ gpui_kit::application().run(|cx| { ### Extend the example -The example keeps its scope small. Extend it where your application needs more: - -- **Persistence.** Entries disappear when the application quits. To keep images across launches, store each body with its `CachePolicy` on disk (`CachePolicy` implements `serde` traits), write files atomically, and remove entries past a size or age limit at startup. A client built on `reqwest` can instead use caching middleware with a disk store, such as [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest). -- **Variants.** Each URL keeps one response. A response that differs by `Vary` replaces the previous variant. -- **Duplicate requests.** Concurrent misses for the same URL each reach the network. GPUI's image loader already shares one load per source. -- **Eviction.** The oldest entry is removed first, whether or not it was used recently. Use an LRU structure when access patterns matter. +- **Size.** The disk store has no size limit. Remove old entries at startup, or clear the directory when it grows past a limit. +- **Proxy and user agent.** Configure them on the `reqwest::Client::builder()`, and return them from `proxy()` and `user_agent()` when other code reads them. +- **Request bodies.** The example reads a request body into memory before sending it. That is fine for images and small requests; wrap the stream with `reqwest::Body::wrap_stream` for large uploads. ## Troubleshooting diff --git a/website/zh-CN/docs/image.md b/website/zh-CN/docs/image.md index 3ea9ac0a0f..cf444fc10e 100644 --- a/website/zh-CN/docs/image.md +++ b/website/zh-CN/docs/image.md @@ -196,202 +196,125 @@ GPUI 的图片缓存位于 App 的 `HttpClient` 之上。它在内存中按来 ### 在 HTTP 层缓存 -要跨视图、跨启动复用响应,可以用一个遵循 HTTP 缓存规则的客户端包装应用原有的 `HttpClient`,并在启动时安装一次。此后所有远程图片都会受益,包括不由应用自己编写的代码加载的图片。实现时遵循以下规则: +要跨视图、跨启动复用响应,可以在启动时安装一个遵循 HTTP 缓存规则的 `HttpClient`。此后所有远程图片都会受益,包括不由应用自己编写的代码加载的图片。实现时遵循以下规则: - **只缓存不带请求体的 GET。** 其他请求原样转发。 - **按共享缓存处理。** 整个应用共用一个客户端:应用自己的视图、扩展和文档视图都经过它。`no-store` 或 `private` 响应,以及对带 `Authorization` 请求的响应,除非服务器明确允许共享缓存保存,否则都不能存储。如果缓存下层会附加 Cookie 或令牌,缓存看不到这些凭据,因此应在缓存之上添加凭据,或者不缓存这些主机。 - **用重新验证代替重新下载。** 新鲜的响应直接返回,不访问网络。过期后发送 `If-None-Match` 或 `If-Modified-Since`;收到 `304 Not Modified` 时,用更新后的响应头返回已存储的响应体。 -- **遵守调用方的重定向策略。** `img()` 会跟随重定向,但自行授权每一跳的加载器会请求 `RedirectPolicy::NoFollow`,并且必须拿到 `3xx` 响应。把策略放进缓存键,跟随重定向得到的结果就永远不会回应这类调用方;重定向响应本身也按同样的规则缓存。 -- **限制内存和磁盘用量。** 同时限制单个条目和总大小,淘汰旧条目;过大而不宜保存的响应直接以流的形式转发。 +- **遵守调用方的重定向策略。** `img()` 会跟随重定向,但自行授权每一跳的加载器会请求 `RedirectPolicy::NoFollow`,并且必须拿到 `3xx` 响应。每种策略使用各自的缓存,跟随重定向得到的结果就永远不会回应这类调用方。 +- **限制内存和磁盘用量。** 限制缓存大小,并清理旧条目。 - **先授权,再请求。** 同一个 URL,缓存会回应任何请求它的调用方。判断调用方能否访问某个 URL 的检查,例如 gpui-shell 脚本的网络授权,必须在 `send` 之前完成。这样缓存不会扩大调用方的访问范围,只是省去重复一次已经获准的请求。 ### 示例 -下面的示例在内存中实现这些规则。[http-cache-semantics](https://crates.io/crates/http-cache-semantics) crate 负责 HTTP 缓存规则的判断:新鲜度、验证器、`Vary` 以及共享缓存的限制。在 `Cargo.toml` 中加入 `http-cache-semantics = "2"`、`futures`、`bytes` 和 `anyhow`。 +[http-cache-reqwest](https://crates.io/crates/http-cache-reqwest) 以 [reqwest-middleware](https://crates.io/crates/reqwest-middleware) 中间件的形式实现了这些规则:新鲜度判断、基于 `ETag` 和 `Last-Modified` 的重新验证、共享缓存的存储限制,并自带磁盘存储。应用只需把它适配到 GPUI 的 `HttpClient`。添加以下依赖: + +```toml +[dependencies] +anyhow = "1" +futures = "0.3" +http-cache-reqwest = "0.15" +reqwest = { version = "0.12", features = ["stream"] } +reqwest-middleware = "0.4" +tokio = { version = "1", features = ["rt-multi-thread"] } +``` ```rust -use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex}, - time::SystemTime, -}; +use std::{path::Path, sync::LazyLock}; -use bytes::Bytes; -use futures::{AsyncReadExt as _, FutureExt as _, future::BoxFuture, io::Cursor}; +use futures::{AsyncReadExt as _, FutureExt as _, TryStreamExt as _, future::BoxFuture}; use gpui_kit::http_client::{ - AsyncBody, HttpClient, Inner, Method, RedirectPolicy, Request, Response, Url, - http::{HeaderValue, response}, + AsyncBody, HttpClient, RedirectPolicy, Request, Response, Url, http::HeaderValue, }; -use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; +use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions}; +use reqwest::redirect; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; + +/// reqwest needs a tokio runtime; GPUI's executors are not one. +static RUNTIME: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("failed to start the HTTP runtime") +}); -/// An [`HttpClient`] that answers GET requests from memory when HTTP caching -/// rules allow it, and forwards everything else to `inner`. -pub struct CachingHttpClient { - inner: Arc, - store: Arc>, +/// A reqwest client with an HTTP cache on disk. +pub struct CachedHttpClient { + follow: ClientWithMiddleware, + no_follow: ClientWithMiddleware, } -impl CachingHttpClient { - pub fn new(inner: Arc, max_bytes: usize) -> Self { - let store = Store { - max_bytes, - ..Default::default() +impl CachedHttpClient { + pub fn new(cache_dir: &Path) -> anyhow::Result { + let client = |policy, dir| -> anyhow::Result<_> { + let client = reqwest::Client::builder().redirect(policy).build()?; + Ok(ClientBuilder::new(client) + .with(Cache(HttpCache { + mode: CacheMode::Default, + manager: CACacheManager { + path: cache_dir.join(dir), + }, + options: HttpCacheOptions::default(), + })) + .build()) }; - Self { - inner, - store: Arc::new(Mutex::new(store)), - } - } -} - -/// The URI plus the caller's redirect policy. A caller that disables -/// redirects must receive the 3xx itself, never a followed result. -type Key = (String, Option); - -struct Entry { - policy: CachePolicy, - body: Bytes, -} - -#[derive(Default)] -struct Store { - entries: HashMap, - /// Keys in insertion order; the oldest is evicted first. - order: VecDeque, - bytes: usize, - max_bytes: usize, -} - -impl Store { - /// A single response may use at most an eighth of the budget. - fn max_entry_bytes(&self) -> usize { - self.max_bytes / 8 - } - - fn get(&self, key: &Key) -> Option<(CachePolicy, Bytes)> { - let entry = self.entries.get(key)?; - Some((entry.policy.clone(), entry.body.clone())) - } - - fn insert(&mut self, key: Key, policy: CachePolicy, body: Bytes) { - self.remove(&key); - self.bytes += body.len(); - self.order.push_back(key.clone()); - self.entries.insert(key, Entry { policy, body }); - while self.bytes > self.max_bytes { - let Some(oldest) = self.order.pop_front() else { - break; - }; - if let Some(entry) = self.entries.remove(&oldest) { - self.bytes -= entry.body.len(); - } - } - } - - fn remove(&mut self, key: &Key) { - if let Some(entry) = self.entries.remove(key) { - self.bytes -= entry.body.len(); - self.order.retain(|k| k != key); - } + // A caller that disables redirects must receive the 3xx itself. It + // gets a client that never follows them, with a cache of its own. + Ok(Self { + follow: client(redirect::Policy::default(), "follow")?, + no_follow: client(redirect::Policy::none(), "no-follow")?, + }) } } -fn respond(head: response::Parts, body: Bytes) -> Response { - Response::from_parts(head, AsyncBody::from_bytes(body)) -} - -impl HttpClient for CachingHttpClient { +impl HttpClient for CachedHttpClient { fn user_agent(&self) -> Option<&HeaderValue> { - self.inner.user_agent() + None } fn proxy(&self) -> Option<&Url> { - self.inner.proxy() + None } fn send( &self, req: Request, ) -> BoxFuture<'static, anyhow::Result>> { - // Only a GET without a body is cacheable; pass everything else through. - if req.method() != Method::GET || !matches!(req.body().0, Inner::Empty) { - return self.inner.send(req); - } - - let inner = self.inner.clone(); - let store = self.store.clone(); + let (head, mut body) = req.into_parts(); + let client = match head.extensions.get::() { + Some(RedirectPolicy::NoFollow) => self.no_follow.clone(), + _ => self.follow.clone(), + }; async move { - let (request, _) = req.into_parts(); - let redirects = request.extensions.get::().cloned(); - let key = (request.uri.to_string(), redirects); - let cached = store.lock().unwrap().get(&key); - - // Fresh: answer without the network. Stale: send the conditional - // headers (If-None-Match / If-Modified-Since) the policy computed, - // keeping the caller's extensions such as its redirect policy. - let mut outgoing = request.clone(); - if let Some((policy, body)) = &cached { - match policy.before_request(&request, SystemTime::now()) { - BeforeRequest::Fresh(head) => return Ok(respond(head, body.clone())), - BeforeRequest::Stale { - request: revalidation, - .. - } => { - outgoing.headers = revalidation.headers; - } - } - } - - let (head, body) = inner - .send(Request::from_parts(outgoing, AsyncBody::empty())) - .await? - .into_parts(); - - // 304: keep the cached body under the refreshed headers. - if let Some((policy, cached_body)) = cached { - match policy.after_response(&request, &head, SystemTime::now()) { - AfterResponse::NotModified(policy, head) => { - let mut store = store.lock().unwrap(); - store.insert(key, policy, cached_body.clone()); - return Ok(respond(head, cached_body)); - } - AfterResponse::Modified(..) => {} - } - } - - // `CachePolicy::new` evaluates the response as a shared cache: - // `no-store` and `private` responses, and most responses to requests - // carrying `Authorization`, are not storable. - let policy = CachePolicy::new(&request, &head); - if !policy.is_storable() { - store.lock().unwrap().remove(&key); - return Ok(Response::from_parts(head, body)); - } - - let limit = store.lock().unwrap().max_entry_bytes(); let mut bytes = Vec::new(); - let mut reader = body.take(limit as u64 + 1); - reader.read_to_end(&mut bytes).await?; - let body = reader.into_inner(); - if bytes.len() > limit { - // Too large to keep: return what was read, then the rest of the stream. - store.lock().unwrap().remove(&key); - let rest = Cursor::new(bytes).chain(body); - return Ok(Response::from_parts(head, AsyncBody::from_reader(rest))); - } - - let bytes = Bytes::from(bytes); - store.lock().unwrap().insert(key, policy, bytes.clone()); - Ok(respond(head, bytes)) + body.read_to_end(&mut bytes).await?; + let request = client + .request(head.method, head.uri.to_string()) + .headers(head.headers) + .body(bytes); + let response = RUNTIME.spawn(request.send()).await??; + + let mut builder = Response::builder() + .status(response.status()) + .version(response.version()); + *builder.headers_mut().unwrap() = response.headers().clone(); + let body = response + .bytes_stream() + .map_err(std::io::Error::other) + .into_async_read(); + Ok(builder.body(AsyncBody::from_reader(body))?) } .boxed() } } ``` +GPUI 自带的 reqwest 客户端会按每个请求的 `RedirectPolicy` 处理跳转,而 `reqwest-middleware` 客户端的跳转策略在构建时就固定了。所以示例构建了两个客户端:一个跟随跳转,供 `img()` 使用;另一个从不跟随,供请求 `RedirectPolicy::NoFollow` 的调用方使用。两者的缓存目录也要分开,因为跟随跳转的客户端前面的缓存,会把最终响应存在原始 URL 下,再拿它回应另一类调用方。`RedirectPolicy::FollowLimit` 使用 reqwest 默认的 10 次上限。 + ### 安装客户端 -在应用原有的客户端外层安装这个包装。这里的 `reqwest_client` 是 `gpui-pre-reqwest-client` crate,版本与所用 `gpui-kit` 固定的 GPUI 快照一致: +在启动时安装一次,要在任何窗口加载远程图片之前。缓存目录请使用平台的缓存目录,例如用 [dirs](https://crates.io/crates/dirs) crate 获取,不要像这里一样放在临时目录: ```rust use std::sync::Arc; @@ -399,10 +322,9 @@ use std::sync::Arc; gpui_kit::application().run(|cx| { gpui_kit::init(cx); - let network = reqwest_client::ReqwestClient::user_agent("my-app/1.0") - .expect("failed to create the HTTP client"); - let cached = CachingHttpClient::new(Arc::new(network), 64 * 1024 * 1024); - cx.set_http_client(Arc::new(cached)); + let cache_dir = std::env::temp_dir().join("my-app/http-cache"); + let client = CachedHttpClient::new(&cache_dir).expect("failed to create the HTTP client"); + cx.set_http_client(Arc::new(client)); // Open windows here. }); @@ -410,12 +332,9 @@ gpui_kit::application().run(|cx| { ### 扩展示例 -这个示例刻意保持简短,应用有需要时可以在以下方面扩展: - -- **持久化。** 应用退出后条目随之消失。要跨启动保留图片,可以把每个响应体连同其 `CachePolicy` 存到磁盘(`CachePolicy` 实现了 `serde` 的 trait),以原子方式写入文件,并在启动时清理超出大小或时间限制的条目。基于 `reqwest` 的客户端也可以改用带磁盘存储的缓存中间件,例如 [http-cache-reqwest](https://crates.io/crates/http-cache-reqwest)。 -- **变体。** 每个 URL 只保留一个响应。因 `Vary` 而不同的响应会替换之前的变体。 -- **重复请求。** 同一 URL 的并发未命中会各自访问网络。GPUI 的图片加载器本身已经让同一来源只加载一次。 -- **淘汰策略。** 总是先移除最早存入的条目,不管它最近是否被使用。如果访问模式很重要,可以改用 LRU 结构。 +- **大小。** 磁盘存储没有大小上限。可以在启动时删除旧条目,或在目录超过上限时清空它。 +- **代理与 User-Agent。** 在 `reqwest::Client::builder()` 上配置,其他代码需要读取时,再从 `proxy()` 和 `user_agent()` 返回。 +- **请求体。** 示例在发送前把请求体读入内存,这对图片和小请求没有问题;上传大文件时,改用 `reqwest::Body::wrap_stream` 包装数据流。 ## 常见问题