diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index e5077b5..2a95d27 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -11,7 +11,9 @@ acord-core = { path = "../core" } iced_wgpu = "0.14" iced_graphics = "0.14" iced_runtime = "0.14" -iced_widget = { version = "0.14", features = ["wgpu", "markdown", "canvas"] } +iced_widget = { version = "0.14", features = ["wgpu", "markdown", "canvas", "image"] } +image = "0.25" +dirs = "6" wgpu = "27" raw-window-handle = "0.6" pollster = "0.4" diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index d618aeb..bda2e96 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -221,11 +221,34 @@ impl ComputedTree { } } +/// Layer 4: embedded image from `![alt](src)`. +#[derive(Debug, Clone)] +pub struct ComputedImage { + pub anchor: Anchor, + pub src: String, + pub alt: String, + /// Pre-computed display height based on image aspect ratio and editor + /// width. Falls back to a placeholder height while loading. + pub display_height: f32, +} + +/// Cached image data keyed by source path/URL. +pub struct ImageCacheEntry { + pub bytes: Vec, + pub width: u32, + pub height: u32, +} + +const IMAGE_PLACEHOLDER_H: f32 = 24.0; +const IMAGE_MAX_H: f32 = 600.0; +const IMAGE_PADDING: f32 = 48.0; + /// Ref to a layer item for interleaved rendering. enum LayerItem<'a> { Inline(&'a InlineResult), Table(&'a ComputedTable), Tree(&'a ComputedTree), + Image(&'a ComputedImage), } impl LayerItem<'_> { @@ -234,6 +257,7 @@ impl LayerItem<'_> { Self::Inline(r) => r.element_height(line_h), Self::Table(t) => t.element_height(line_h), Self::Tree(t) => t.element_height(font_size), + Self::Image(img) => img.display_height, } } } @@ -382,6 +406,10 @@ pub struct EditorState { /// the shell drains it after each frame via `viewport_take_clipboard` /// and pushes the text to the system clipboard. pub pending_clipboard: Option, + + // ── Images ── + pub computed_images: Vec, + pub image_cache: HashMap, } /// Per-eval table name→id bookkeeping. `keys` is every alias a table is @@ -495,6 +523,8 @@ impl EditorState { line_indicator: LineIndicator::On, gutter_rainbow: true, pending_clipboard: None, + computed_images: Vec::new(), + image_cache: HashMap::new(), } } @@ -579,6 +609,7 @@ impl EditorState { self.eval_results.retain(|r| !ids.contains(&r.anchor.block_id)); self.computed_tables.retain(|t| !ids.contains(&t.anchor.block_id)); self.computed_trees.retain(|t| !ids.contains(&t.anchor.block_id)); + self.computed_images.retain(|img| !ids.contains(&img.anchor.block_id)); } /// Map a line number in concatenated module source back to a per-block anchor. @@ -603,6 +634,63 @@ impl EditorState { } } + /// Scan text blocks for `![alt](src)` image references and populate + /// `computed_images`. Loads image bytes into `image_cache` on first + /// encounter (sync for local files). Replaces previous images for the + /// given block set — unchanged sources keep their cache entry. + fn scan_images( + &mut self, + boundaries: &[(usize, crate::selection::BlockId)], + block_ids: &[crate::selection::BlockId], + ) { + self.computed_images.retain(|img| !block_ids.contains(&img.anchor.block_id)); + + let mut new_srcs: Vec<(Anchor, String, String)> = Vec::new(); + for &(start, block_id) in boundaries { + let block = match self.registry.get(&block_id) { + Some(b) => b, + None => continue, + }; + let text = if let Some(tb) = block.as_any().downcast_ref::() { + tb.content.text() + } else { + continue; + }; + for (line_idx, line) in text.lines().enumerate() { + if let Some((alt, src)) = parse_image_ref(line) { + let anchor = Anchor { block_id, after_line: line_idx }; + new_srcs.push((anchor, src, alt)); + } + } + } + + // Editor width estimate for aspect-ratio scaling. + let editor_w = 800.0f32; // approximate; TODO: pass actual width + + for (anchor, src, alt) in new_srcs { + // Load into cache if absent. + if !self.image_cache.contains_key(&src) { + if let Some(entry) = load_image_from_path(&src) { + self.image_cache.insert(src.clone(), entry); + } + } + let display_height = if let Some(entry) = self.image_cache.get(&src) { + let max_w = (editor_w - IMAGE_PADDING).max(1.0); + let scale_w = max_w.min(entry.width as f32); + let aspect = entry.height as f32 / entry.width.max(1) as f32; + (scale_w * aspect).min(IMAGE_MAX_H) + } else { + IMAGE_PLACEHOLDER_H + }; + self.computed_images.push(ComputedImage { + anchor, + src, + alt, + display_height, + }); + } + } + fn block_index_at_line(&self, global_line: usize) -> Option { for (i, &id) in self.layout.iter().enumerate() { if let Some(block) = self.registry.get(&id) { @@ -1935,6 +2023,9 @@ impl EditorState { } let source = source_parts.join("\n"); + // Image scan runs regardless of eval content. + self.scan_images(&boundaries, &block_ids); + let has_text_eval = source.lines().any(|l| l.trim_start().starts_with("/=")); let has_cell_formulas = self.any_visible_cell_formulas(); if !has_text_eval && !has_cell_formulas { @@ -3536,6 +3627,11 @@ impl EditorState { items.push((ct.anchor.after_line, LayerItem::Tree(ct))); } } + for img in &self.computed_images { + if img.anchor.block_id == block_id { + items.push((img.anchor.after_line, LayerItem::Image(img))); + } + } items.sort_by_key(|(line, _)| *line); items } @@ -3634,6 +3730,36 @@ impl EditorState { element: el, }); } + LayerItem::Image(img) => { + let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = + if let Some(entry) = self.image_cache.get(&img.src) { + let handle = iced_widget::image::Handle::from_bytes(entry.bytes.clone()); + iced_widget::container( + iced_widget::image(handle) + .width(Length::Fill) + .height(Length::Fixed(img.display_height)) + ) + .padding(Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 40.0 }) + .width(Length::Fill) + .into() + } else { + // Placeholder while loading or on failure. + iced_widget::container( + iced_widget::text(format!("[image: {}]", img.alt)) + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(p.overlay0) + ) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill) + .into() + }; + anchored.push(AnchoredItem { + after_line: *after_line, + height: item.element_height(lh, self.font_size), + element: el, + }); + } } } @@ -4280,3 +4406,42 @@ fn leading_whitespace(line: &str) -> &str { &line[..end] } +/// Parse a markdown image reference `![alt](src)` from a line. Returns +/// `(alt, src)` if found. Only matches if the `![` is the first +/// non-whitespace on the line (inline images inside text are not rendered +/// as block-level anchored items). +fn parse_image_ref(line: &str) -> Option<(String, String)> { + let trimmed = line.trim_start(); + if !trimmed.starts_with("![") { return None; } + let after_bang = &trimmed[2..]; + let close_bracket = after_bang.find(']')?; + let alt = after_bang[..close_bracket].to_string(); + let rest = &after_bang[close_bracket + 1..]; + if !rest.starts_with('(') { return None; } + let close_paren = rest.find(')')?; + let src = rest[1..close_paren].trim().to_string(); + if src.is_empty() { return None; } + Some((alt, src)) +} + +/// Load an image from a local filesystem path into an `ImageCacheEntry`. +/// Returns `None` on any failure (missing file, corrupt image, etc.). +fn load_image_from_path(src: &str) -> Option { + // Expand ~ to home directory. + let path = if src.starts_with("~/") { + dirs::home_dir()?.join(&src[2..]) + } else { + std::path::PathBuf::from(src) + }; + let bytes = std::fs::read(&path).ok()?; + let reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) + .with_guessed_format() + .ok()?; + let dims = reader.into_dimensions().ok()?; + Some(ImageCacheEntry { + bytes, + width: dims.0, + height: dims.1, + }) +} +