lattice_mode/modes/image.rs
1//! `image-mode` — the major mode for a buffer whose file is a picture.
2//!
3//! `:e diagram.png` used to fail on the UTF-8 read: `Document::open` is
4//! `read_to_string`, and a PNG is not text. This mode is the other answer —
5//! the file becomes an ordinary buffer with an ordinary major, listed by
6//! `:ls`, reached by `:bn`, named in the modeline — and what it shows is the
7//! image, through the same inline-media substrate an org `[[file:…]]` block
8//! uses.
9//!
10//! ## It presents, it does not edit
11//!
12//! [`Mode::presents_extensions`] is what tells the open path not to read the
13//! bytes. The buffer holds a single empty line and the picture hangs below it
14//! as a media block, so nothing about the file's contents is in the rope.
15//!
16//! That makes read-only **load-bearing rather than tidy**: the buffer's text
17//! is not the file, so writing it back would replace the image with nothing.
18//! Both declarations are present and both are needed —
19//! [`ReadOnly`](lattice_config::ReadOnly) gates insert-mode typing, and the
20//! implied `read-only-mode` carries the invocation runner that refuses `dd`,
21//! `x`, `cw` and `p`. The option alone would let an operator through.
22//!
23//! The third gate is not here: `:w` is refused host-side, because a save does
24//! not go through either of the above.
25
26use crate::{
27 CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
28};
29
30/// Extensions `image-mode` claims.
31///
32/// The same set org's inline-image scanner allows, and deliberately so: a
33/// file that draws inline must draw when opened directly, or the two surfaces
34/// disagree about what an image is. An allow-list rather than sniffing — a
35/// file is opened because the user asked for it, and guessing at its type by
36/// reading it is how a text file with a stray byte becomes a broken picture.
37pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
38
39/// Major mode for a buffer backed by an image file.
40pub struct ImageMode;
41
42impl ImageMode {
43 /// The canonical id, `"image-mode"` — what [`Mode::id`](crate::Mode::id)
44 /// returns. Use it to name this mode without an instance (activation,
45 /// `implies`, keymap layers, tests).
46 pub fn mode_id() -> ModeId {
47 ModeId::new("image-mode")
48 }
49}
50
51impl Mode for ImageMode {
52 type Guard = ();
53
54 fn id(&self) -> ModeId {
55 Self::mode_id()
56 }
57
58 fn kind(&self) -> ModeKind {
59 ModeKind::Major
60 }
61
62 fn required_capabilities(&self) -> CapabilitySet {
63 CapabilitySet::empty()
64 }
65
66 fn presents_extensions(&self) -> &[&'static str] {
67 IMAGE_EXTENSIONS
68 }
69
70 /// Read-only, half one: this gates insert-mode typing.
71 fn options(&self) -> OptionOverrideSet {
72 lattice_config::overrides! {
73 lattice_config::ReadOnly = true,
74 }
75 }
76
77 /// Read-only, half two: `read-only-mode` carries the invocation runner
78 /// that refuses the operators. Declared on the MAJOR, because an implied
79 /// mode is followed from the mode being activated — putting it on a
80 /// shared minor looks tidier and does not fire.
81 fn implies(&self) -> &[ModeId] {
82 static IMPLIED: std::sync::OnceLock<Vec<ModeId>> = std::sync::OnceLock::new();
83 IMPLIED.get_or_init(|| vec![crate::modes::ReadOnlyMode::mode_id()])
84 }
85
86 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
87 // Nothing to set up: the picture comes from the media source this
88 // module registers at boot, which reads the buffer's path.
89 Box::pin(async { Ok(()) })
90 }
91}
92
93/// The media producer that draws an `image-mode` buffer's own file.
94///
95/// The mode owns its picture the way org owns its inline ones: through the
96/// media-source registry, off the render path, sized and decoded by the host.
97/// There is no image-specific code in the renderer or the host because of it —
98/// an `image-mode` buffer is one block anchored at line 0, which is the same
99/// thing an org buffer produces several of.
100#[derive(Debug)]
101pub struct ImageFileMediaSource;
102
103/// This producer's teardown key.
104///
105/// Deliberately far from any `PluginId`, which are small sequential integers:
106/// the registry is keyed by `source_id` and a collision would make a plugin
107/// reload silently unregister this.
108pub const IMAGE_FILE_SOURCE_ID: u64 = 0x494D_4147_4500_0001; // "IMAGE"
109
110impl crate::media_source::AsyncMediaSource for ImageFileMediaSource {
111 fn source_id(&self) -> u64 {
112 IMAGE_FILE_SOURCE_ID
113 }
114
115 fn produce(
116 &self,
117 _buffer_id: u64,
118 path: Option<std::path::PathBuf>,
119 _line_count: u32,
120 _text: String,
121 ) -> crate::media_source::MediaFuture<'_> {
122 // `Ok(vec![])` rather than `Err` for a buffer that is not an image:
123 // an error means "keep what you had", and the truthful answer here is
124 // "I looked, there is nothing of mine". Every ordinary buffer in the
125 // editor takes this path, so it must also be free — it is one
126 // extension comparison and no I/O.
127 let block =
128 path.filter(|p| is_image_path(p))
129 .map(|p| crate::media_source::MediaBlockRequest {
130 anchor_line: 0,
131 path: p,
132 // `None`, so `MediaBlock::new` falls back to the file name.
133 // That name is what shows if the picture cannot be decoded,
134 // and for a buffer whose whole content IS the file, the file
135 // name is the most useful thing to say.
136 alt: None,
137 // Never upscale: an icon blown up to fill the pane is worse
138 // than the icon.
139 fit: lattice_cells::MediaFit::Contain,
140 });
141 Box::pin(async move { Ok(block.into_iter().collect()) })
142 }
143}
144
145/// Register [`ImageFileMediaSource`] against the boot-time media registry.
146///
147/// Called from the host's boot beside the registry's creation. It lives here,
148/// with the mode, rather than in the host: the major and the producer that
149/// draws its buffers are one surface, and splitting them is how half of a
150/// mode ends up in the host.
151pub fn register_image_media_source(registry: &crate::media_source::MediaSourceRegistryHandle) {
152 let producer: std::sync::Arc<dyn crate::media_source::AsyncMediaSource> =
153 std::sync::Arc::new(ImageFileMediaSource);
154 registry.rcu(|current| {
155 let mut next = (**current).clone();
156 next.register(producer.clone());
157 std::sync::Arc::new(next)
158 });
159}
160
161/// True when `path` is a file `image-mode` presents.
162///
163/// Shared by the mode's claim and its media producer so the two cannot
164/// disagree about which files are pictures.
165pub fn is_image_path(path: &std::path::Path) -> bool {
166 path.extension()
167 .and_then(|e| e.to_str())
168 .map(|e| e.to_ascii_lowercase())
169 .is_some_and(|e| IMAGE_EXTENSIONS.contains(&e.as_str()))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn id_and_kind() {
178 assert_eq!(ImageMode.id().as_str(), "image-mode");
179 assert_eq!(ImageMode.kind(), ModeKind::Major);
180 }
181
182 /// The buffer's text is a placeholder, so a write would replace the image
183 /// with nothing. BOTH declarations are required: the option gates typing
184 /// and nothing else, and operators reach the document through their own
185 /// path.
186 #[test]
187 fn read_only_is_declared_twice_because_one_declaration_is_not_enough() {
188 let opts = ImageMode.options();
189 assert!(
190 opts.iter()
191 .any(|o| o.option_type_id == std::any::TypeId::of::<lattice_config::ReadOnly>()),
192 "the ReadOnly option gates insert-mode typing"
193 );
194 assert!(
195 ImageMode
196 .implies()
197 .contains(&crate::modes::ReadOnlyMode::mode_id()),
198 "read-only-mode carries the invocation runner that refuses operators"
199 );
200 }
201
202 /// The producer answers for its own buffer and stays silent — not
203 /// errorful — everywhere else. An `Err` means "keep the blocks you had",
204 /// which for an ordinary text buffer would be a lie.
205 #[tokio::test]
206 async fn the_producer_emits_one_block_for_an_image_buffer_and_none_otherwise() {
207 use crate::media_source::AsyncMediaSource;
208 let src = ImageFileMediaSource;
209
210 let blocks = src
211 .produce(1, Some("/a/shot.png".into()), 1, String::new())
212 .await
213 .expect("an image buffer is not an error");
214 assert_eq!(blocks.len(), 1);
215 assert_eq!(blocks[0].anchor_line, 0, "the block hangs below line 0");
216 assert_eq!(blocks[0].path, std::path::Path::new("/a/shot.png"));
217 assert_eq!(blocks[0].alt, None, "so the file-name fallback applies");
218
219 for other in [Some("/a/notes.org".into()), None] {
220 let blocks: Vec<_> = src
221 .produce(1, other, 1, String::new())
222 .await
223 .expect("a non-image buffer is not an error either");
224 assert!(blocks.is_empty());
225 }
226 }
227
228 #[test]
229 fn it_claims_the_extensions_org_draws_inline() {
230 assert!(is_image_path(std::path::Path::new("/a/b.png")));
231 assert!(
232 is_image_path(std::path::Path::new("/a/B.JPEG")),
233 "case-insensitive"
234 );
235 assert!(!is_image_path(std::path::Path::new("/a/notes.org")));
236 assert!(
237 !is_image_path(std::path::Path::new("/a/png")),
238 "no extension at all"
239 );
240 }
241}