Skip to main content

lattice_plugin_host/
media_source.rs

1//! IM.6b — the `WasmMediaSource` adapter.
2//!
3//! Wraps a media plugin's [`MediaClient`] bridge and exposes a **native**-typed
4//! producer the host calls off the render path, exactly like
5//! `WasmDecorationSource`. The renderer reads the cache; it never calls WASM.
6
7use std::path::{Path, PathBuf};
8
9use lattice_mode::media_source::{AsyncMediaSource, MediaBlockRequest, MediaFuture};
10
11use crate::PluginId;
12use crate::boundary_decoration::project_decoration_context;
13use crate::media_task::MediaClient;
14
15/// An async media-block producer over a plugin's [`MediaClient`].
16#[derive(Clone, Debug)]
17pub struct WasmMediaSource {
18    client: MediaClient,
19}
20
21impl AsyncMediaSource for WasmMediaSource {
22    fn source_id(&self) -> u64 {
23        self.plugin_id().0 as u64
24    }
25
26    fn produce(
27        &self,
28        buffer_id: u64,
29        path: Option<PathBuf>,
30        line_count: u32,
31        text: String,
32    ) -> MediaFuture<'_> {
33        Box::pin(async move {
34            self.media_blocks(buffer_id, path.as_deref(), line_count, text)
35                .await
36        })
37    }
38}
39
40impl WasmMediaSource {
41    pub fn new(client: MediaClient) -> Self {
42        Self { client }
43    }
44
45    pub fn plugin_id(&self) -> PluginId {
46        self.client.id()
47    }
48
49    /// Produce a buffer's media blocks — the async producer the host calls OFF
50    /// the render path.
51    ///
52    /// Graceful: the outer host error (trap / plugin-gone) and the inner guest
53    /// WIT `err` both collapse to the `String` the caller logs, and on an `Err`
54    /// the caller keeps the buffer's PRIOR blocks rather than clearing them. A
55    /// transient failure mid-edit must not make every image in the document
56    /// blink out.
57    ///
58    /// A block naming a path that does not resolve is DROPPED here with a
59    /// warning rather than failing the batch — one broken link in an org file
60    /// should not cost the reader every other image on the page.
61    pub async fn media_blocks(
62        &self,
63        buffer_id: u64,
64        path: Option<&Path>,
65        line_count: u32,
66        text: String,
67    ) -> Result<Vec<MediaBlockRequest>, String> {
68        let ctx = project_decoration_context(buffer_id, path, line_count);
69        let wit: Vec<crate::media_task::MediaBlock> = match self.client.produce(ctx, text).await {
70            Ok(inner) => inner?,
71            Err(host_err) => return Err(format!("media plugin: {host_err}")),
72        };
73        // Relative paths resolve against the BUFFER's directory, which is what
74        // an org `[[file:diagram.png]]` means — not the editor's cwd, which is
75        // wherever the user happened to launch from.
76        let base = path.and_then(|p| p.parent()).map(Path::to_path_buf);
77        Ok(wit
78            .into_iter()
79            .filter_map(|b| resolve_block(b, base.as_deref()))
80            .collect())
81    }
82}
83
84/// Convert one WIT block, resolving its path. `None` drops the block.
85///
86/// Three shapes, in order: `~` expands, an absolute path is taken as given, and
87/// anything else joins onto the buffer's directory.
88fn resolve_block(
89    b: crate::media_task::MediaBlock,
90    base: Option<&Path>,
91) -> Option<MediaBlockRequest> {
92    if b.path.trim().is_empty() {
93        tracing::warn!("media block with an empty path; dropped");
94        return None;
95    }
96    // `~` expands first, by the same rule as every other path a user writes
97    // (`lattice_core::home`). Without it a `[[file:~/pics/shot.png]]` is not
98    // absolute, so it falls through to the join below and becomes
99    // `<buffer dir>/~/pics/shot.png` — a path that cannot exist, and one that
100    // fails the way all of these fail: silently, with the picture simply not
101    // appearing.
102    let raw = lattice_core::home::expand_tilde_path(Path::new(&b.path));
103    // `has_root`, not `is_absolute`: on Windows `/pics/x.png` has no drive so
104    // is not "absolute", but it names the current drive's root — it is not a
105    // reference relative to the buffer, and must not be dropped as one.
106    let resolved = if raw.has_root() {
107        raw
108    } else {
109        match base {
110            Some(dir) => dir.join(raw),
111            // No buffer path to resolve against (a scratch buffer). A relative
112            // reference is meaningless there, and guessing the cwd would open
113            // whatever happens to sit beside the launch directory.
114            None => {
115                tracing::warn!(path = %b.path, "relative media path in a pathless buffer; dropped");
116                return None;
117            }
118        }
119    };
120    Some(MediaBlockRequest {
121        anchor_line: b.anchor_line,
122        path: resolved,
123        alt: b.alt.filter(|a| !a.trim().is_empty()),
124        fit: match b.fit {
125            crate::media_task::MediaFit::Contain => lattice_cells::MediaFit::Contain,
126            crate::media_task::MediaFit::Width => lattice_cells::MediaFit::Width,
127        },
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::media_task::{MediaBlock as WitBlock, MediaFit as WitFit};
135
136    fn wit(path: &str) -> WitBlock {
137        WitBlock {
138            anchor_line: 3,
139            path: path.to_string(),
140            alt: None,
141            fit: WitFit::Contain,
142        }
143    }
144
145    /// `[[file:diagram.png]]` means "beside this org file", not "beside
146    /// wherever the editor was launched from".
147    #[test]
148    fn a_relative_path_resolves_against_the_buffers_directory() {
149        let base = Path::new("/home/u/notes");
150        let got = resolve_block(wit("img/diagram.png"), Some(base)).expect("resolves");
151        assert_eq!(got.path, Path::new("/home/u/notes/img/diagram.png"));
152    }
153
154    /// `~/pics/shot.png` is not absolute, so without expansion it fell through
155    /// to the join and became `<buffer dir>/~/pics/shot.png`. The image then
156    /// never appeared and nothing said why.
157    #[test]
158    fn a_tilde_path_expands_rather_than_being_joined_onto_the_buffer_dir() {
159        let home = dirs::home_dir().expect("a home directory");
160        let got = resolve_block(wit("~/pics/shot.png"), Some(Path::new("/home/u/notes")))
161            .expect("resolves");
162        assert_eq!(got.path, home.join("pics/shot.png"));
163    }
164
165    /// `~someone` is not ours to resolve, and `<home>someone` would be a
166    /// plausible path to the wrong place. It stays relative, and so joins.
167    #[test]
168    fn a_tilde_user_path_is_not_resolved_against_our_home() {
169        let got = resolve_block(wit("~someone/x.png"), Some(Path::new("/home/u/notes")))
170            .expect("resolves");
171        assert_eq!(got.path, Path::new("/home/u/notes/~someone/x.png"));
172    }
173
174    #[test]
175    fn an_absolute_path_is_left_alone() {
176        let got = resolve_block(wit("/tmp/x.png"), Some(Path::new("/home/u"))).unwrap();
177        assert_eq!(got.path, Path::new("/tmp/x.png"));
178    }
179
180    /// Guessing the cwd would open whatever happens to sit beside the launch
181    /// directory — a wrong image is worse than none.
182    #[test]
183    fn a_relative_path_in_a_pathless_buffer_is_dropped() {
184        assert!(resolve_block(wit("diagram.png"), None).is_none());
185    }
186
187    #[test]
188    fn an_empty_path_is_dropped_rather_than_resolved() {
189        assert!(resolve_block(wit("   "), Some(Path::new("/a"))).is_none());
190    }
191
192    /// A blank alt is the same as none, so `MediaBlock::new`'s file-name
193    /// fallback takes over rather than showing an empty box.
194    #[test]
195    fn a_blank_alt_becomes_none_so_the_filename_fallback_applies() {
196        let mut b = wit("/x.png");
197        b.alt = Some("  ".into());
198        assert_eq!(resolve_block(b, None).unwrap().alt, None);
199    }
200}