lattice_mode/media_source.rs
1//! The registry of inline-media producers (IM.6b).
2//!
3//! The media twin of [`decoration_source`](crate::decoration_source), and the
4//! same contract: an async, off-render-path producer the host drives on a
5//! trigger, whose result it caches. The renderer NEVER calls this.
6
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use arc_swap::ArcSwap;
13
14/// One inline media block a producer wants drawn.
15///
16/// The native mirror of the WIT `media-block` (`wit/media.wit`). Note what is
17/// absent: any size. The producer names a file and a line; the HOST resolves
18/// the intrinsic dimensions and decides how many rows it reserves, so sizing
19/// policy lives in one place and a plugin cannot claim arbitrary vertical
20/// space in a buffer it does not own.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct MediaBlockRequest {
23 /// 0-based source line the block hangs below.
24 pub anchor_line: u32,
25 /// Path to the image, already resolved against the buffer's directory.
26 pub path: PathBuf,
27 /// What a renderer that cannot draw shows instead. `None` falls back to
28 /// the file name — never nothing.
29 pub alt: Option<String>,
30 /// How the image's intrinsic size maps into the block.
31 pub fit: lattice_cells::MediaFit,
32}
33
34/// The boxed future an [`AsyncMediaSource::produce`] returns.
35///
36/// `Ok(blocks)` replaces the buffer's cached blocks; `Err(reason)` means
37/// **keep the prior cached set**, never "clear". A transient failure mid-edit
38/// must not make every image in the document blink out.
39pub type MediaFuture<'a> =
40 Pin<Box<dyn Future<Output = Result<Vec<MediaBlockRequest>, String>> + Send + 'a>>;
41
42/// An async, off-render-path producer of a buffer's inline media blocks.
43pub trait AsyncMediaSource: Send + Sync + std::fmt::Debug {
44 /// Stable id of the producing plugin — the teardown key. Two producers
45 /// with the same id are the same plugin, so a reload replaces rather than
46 /// duplicates.
47 fn source_id(&self) -> u64;
48
49 /// Produce this buffer's media blocks off the render path.
50 /// `text` is the buffer's contents. Passed by value rather than through a
51 /// document handle because a media scan reads EVERY line: a handle would
52 /// cost one boundary crossing per line, where one copy costs one. The copy
53 /// is affordable because this runs on open / edit, not per frame.
54 fn produce(
55 &self,
56 buffer_id: u64,
57 path: Option<PathBuf>,
58 line_count: u32,
59 text: String,
60 ) -> MediaFuture<'_>;
61}
62
63/// Runtime-mutable registry of [`AsyncMediaSource`]s.
64#[derive(Default, Clone)]
65pub struct MediaSourceRegistry {
66 sources: Vec<Arc<dyn AsyncMediaSource>>,
67}
68
69impl std::fmt::Debug for MediaSourceRegistry {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.debug_struct("MediaSourceRegistry")
72 .field("sources", &self.sources.len())
73 .finish()
74 }
75}
76
77impl MediaSourceRegistry {
78 /// An empty registry.
79 pub fn new() -> Self {
80 Self::default()
81 }
82
83 /// Register a producer. Idempotent per `source_id`: a re-register (reload)
84 /// replaces rather than accumulating a duplicate — otherwise every
85 /// `:plugin-reload` would double the images in a buffer.
86 pub fn register(&mut self, source: Arc<dyn AsyncMediaSource>) {
87 let id = source.source_id();
88 self.sources.retain(|s| s.source_id() != id);
89 self.sources.push(source);
90 }
91
92 /// Unregister every producer for `source_id`; returns the count removed.
93 /// No-op when absent, per the teardown contract.
94 pub fn unregister(&mut self, source_id: u64) -> usize {
95 let before = self.sources.len();
96 self.sources.retain(|s| s.source_id() != source_id);
97 before - self.sources.len()
98 }
99
100 /// A wait-free snapshot of the registered producers.
101 pub fn sources(&self) -> Vec<Arc<dyn AsyncMediaSource>> {
102 self.sources.clone()
103 }
104
105 /// True when no producer is registered.
106 pub fn is_empty(&self) -> bool {
107 self.sources.is_empty()
108 }
109
110 /// Number of registered producers.
111 pub fn len(&self) -> usize {
112 self.sources.len()
113 }
114}
115
116/// Boot-service handle. Register **and** look up with this exact alias (the
117/// `ServiceRegistry` TypeId rule).
118pub type MediaSourceRegistryHandle = Arc<ArcSwap<MediaSourceRegistry>>;
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[derive(Debug)]
125 struct Fake(u64);
126 impl AsyncMediaSource for Fake {
127 fn source_id(&self) -> u64 {
128 self.0
129 }
130 fn produce(&self, _b: u64, _p: Option<PathBuf>, _l: u32, _t: String) -> MediaFuture<'_> {
131 Box::pin(async { Ok(Vec::new()) })
132 }
133 }
134
135 /// A reload must REPLACE its producer, not add a second one — otherwise
136 /// every `:plugin-reload` doubles the images in the buffer.
137 #[test]
138 fn re_registering_the_same_source_id_replaces_rather_than_duplicates() {
139 let mut r = MediaSourceRegistry::new();
140 r.register(Arc::new(Fake(7)));
141 r.register(Arc::new(Fake(7)));
142 assert_eq!(r.len(), 1);
143 r.register(Arc::new(Fake(8)));
144 assert_eq!(r.len(), 2);
145 }
146
147 #[test]
148 fn unregister_reports_what_it_removed_and_is_idempotent() {
149 let mut r = MediaSourceRegistry::new();
150 r.register(Arc::new(Fake(7)));
151 assert_eq!(r.unregister(7), 1);
152 assert_eq!(r.unregister(7), 0, "idempotent, per the teardown contract");
153 assert!(r.is_empty());
154 }
155}