Skip to main content

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}