Added image support.

This commit is contained in:
jess 2026-04-17 20:15:34 -07:00
parent d746c9abd6
commit a9beb2a4ae
2 changed files with 168 additions and 1 deletions

View File

@ -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"

View File

@ -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<u8>,
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<String>,
// ── Images ──
pub computed_images: Vec<ComputedImage>,
pub image_cache: HashMap<String, ImageCacheEntry>,
}
/// 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::<TextBlock>() {
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<usize> {
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<ImageCacheEntry> {
// 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,
})
}