Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[net]
git-fetch-with-cli = true
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rewrite SSH URLs to HTTPS
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
Expand All @@ -42,6 +44,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rewrite SSH URLs to HTTPS
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install Linux dependencies
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Rewrite SSH URLs to HTTPS
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
Expand Down Expand Up @@ -120,6 +122,8 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Rewrite SSH URLs to HTTPS
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
Expand Down Expand Up @@ -179,6 +183,8 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Rewrite SSH URLs to HTTPS
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install cached dist
uses: actions/download-artifact@v7
with:
Expand Down Expand Up @@ -229,6 +235,8 @@ jobs:
with:
persist-credentials: false
submodules: recursive
- name: Rewrite SSH URLs to HTTPS
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install cached dist
uses: actions/download-artifact@v7
with:
Expand Down
2 changes: 2 additions & 0 deletions dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ tap = "helgesverre/homebrew-tap"
publish-jobs = ["homebrew"]
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
# Allow custom modifications to generated CI files (SSH-to-HTTPS URL rewriting)
allow-dirty = ["ci"]
# System dependencies for Linux builds
[dist.dependencies.apt]
libxkbcommon-dev = "*"
Expand Down
12 changes: 12 additions & 0 deletions keymap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,15 @@ bindings:
# ===========================================================================
- key: "cmd+shift+v"
command: MarkdownTogglePreview

# ===========================================================================
# Image Viewer
# ===========================================================================
- key: "cmd+="
command: ImageZoomIn
- key: "cmd+-"
command: ImageZoomOut
- key: "cmd+0"
command: ImageFitToWindow
- key: "cmd+shift+0"
command: ImageActualSize
153 changes: 153 additions & 0 deletions src/image/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! Image viewer module
//!
//! Provides image viewing with pan and zoom support.
//! Images are decoded into RGBA pixel buffers and rendered
//! with nearest-neighbor scaling.

pub mod render;

/// State for the image viewer mode
#[derive(Debug, Clone)]
pub struct ImageState {
/// Decoded RGBA pixel data (4 bytes per pixel)
pub pixels: Vec<u8>,
/// Image width in pixels
pub width: u32,
/// Image height in pixels
pub height: u32,
/// File size in bytes (for status bar)
pub file_size: u64,
/// Image format name (e.g. "PNG", "JPEG")
pub format: String,
/// Current zoom level (1.0 = 100%)
pub scale: f64,
/// Pan offset X in image-space pixels
pub offset_x: f64,
/// Pan offset Y in image-space pixels
pub offset_y: f64,
/// Whether the user has manually zoomed (disables auto-fit on resize)
pub user_zoomed: bool,
/// Last known mouse position (content-local coords, for zoom-toward-cursor)
pub last_mouse_x: f64,
/// Last known mouse position (content-local coords, for zoom-toward-cursor)
pub last_mouse_y: f64,
/// Active drag state for panning
pub drag: Option<DragState>,
}

/// Drag state for click-and-drag panning
#[derive(Debug, Clone)]
pub struct DragState {
/// Mouse position when drag started (screen coords)
pub start_mouse_x: f64,
pub start_mouse_y: f64,
/// Image offset when drag started
pub start_offset_x: f64,
pub start_offset_y: f64,
}

impl ImageState {
/// Create a new ImageState from decoded image data.
///
/// Computes initial scale: fit-to-viewport if image is larger,
/// actual size (1.0) if image fits.
pub fn new(
pixels: Vec<u8>,
width: u32,
height: u32,
file_size: u64,
format: String,
viewport_width: u32,
viewport_height: u32,
) -> Self {
let scale = Self::compute_fit_scale(width, height, viewport_width, viewport_height);
Self {
pixels,
width,
height,
file_size,
format,
scale,
offset_x: 0.0,
offset_y: 0.0,
user_zoomed: false,
last_mouse_x: 0.0,
last_mouse_y: 0.0,
drag: None,
}
}

/// Compute scale to fit image within viewport.
/// Returns 1.0 if image already fits, otherwise scales down.
pub fn compute_fit_scale(
img_width: u32,
img_height: u32,
viewport_width: u32,
viewport_height: u32,
) -> f64 {
if viewport_width == 0 || viewport_height == 0 || img_width == 0 || img_height == 0 {
return 1.0;
}
let scale_x = viewport_width as f64 / img_width as f64;
let scale_y = viewport_height as f64 / img_height as f64;
let fit_scale = scale_x.min(scale_y);
// Only scale down, never scale up for auto-fit
fit_scale.min(1.0)
}

/// Get the zoom level as a percentage integer (e.g. 100 for 1.0)
pub fn zoom_percent(&self) -> u32 {
(self.scale * 100.0).round() as u32
}

/// Format file size for display (e.g. "2.4 MB", "128 KB")
pub fn file_size_display(&self) -> String {
if self.file_size >= 1_048_576 {
format!("{:.1} MB", self.file_size as f64 / 1_048_576.0)
} else if self.file_size >= 1024 {
format!("{:.0} KB", self.file_size as f64 / 1024.0)
} else {
format!("{} B", self.file_size)
}
}
}

/// Load and decode an image file into an ImageState.
///
/// Returns None if the file can't be read or decoded.
pub fn load_image(
path: &std::path::Path,
viewport_width: u32,
viewport_height: u32,
) -> Option<ImageState> {
let file_size = std::fs::metadata(path).ok()?.len();

let format = path
.extension()
.and_then(|e| e.to_str())
.map(|e| match e.to_lowercase().as_str() {
"jpg" | "jpeg" => "JPEG".to_string(),
"png" => "PNG".to_string(),
"gif" => "GIF".to_string(),
"bmp" => "BMP".to_string(),
"webp" => "WebP".to_string(),
"ico" => "ICO".to_string(),
other => other.to_uppercase(),
})
.unwrap_or_else(|| "Unknown".to_string());

let img = image::open(path).ok()?;
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
let pixels = rgba.into_raw();

Some(ImageState::new(
pixels,
width,
height,
file_size,
format,
viewport_width,
viewport_height,
))
}
122 changes: 122 additions & 0 deletions src/image/render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Image rendering for the view layer
//!
//! Renders checkerboard background and scaled image pixels
//! into the framebuffer using nearest-neighbor sampling.

use crate::image::ImageState;
use crate::theme::ImagePreviewTheme;
use crate::view::frame::Frame;

/// Render an image in the given screen rectangle.
///
/// 1. Fills the area with checkerboard pattern
/// 2. Blits visible portion of the image with nearest-neighbor scaling
/// 3. Centers the image if it's smaller than the viewport
pub fn render_image(
frame: &mut Frame,
image: &ImageState,
theme: &ImagePreviewTheme,
area_x: usize,
area_y: usize,
area_width: usize,
area_height: usize,
) {
let cell = theme.checkerboard_size.max(1);
let light = theme.checkerboard_light.to_argb_u32();
let dark = theme.checkerboard_dark.to_argb_u32();

// Compute how big the image is on screen
let scaled_width = (image.width as f64 * image.scale) as usize;
let scaled_height = (image.height as f64 * image.scale) as usize;

// Center offset when image is smaller than viewport
let center_x = if scaled_width < area_width {
(area_width - scaled_width) as f64 / 2.0
} else {
0.0
};
let center_y = if scaled_height < area_height {
(area_height - scaled_height) as f64 / 2.0
} else {
0.0
};

let buf_width = frame.width();
let buf_height = frame.height();
let buffer = frame.buffer_mut();
let img_w = image.width;
let img_h = image.height;
let scale = image.scale;
let off_x = image.offset_x;
let off_y = image.offset_y;

for sy in 0..area_height {
let screen_y = area_y + sy;
if screen_y >= buf_height {
break;
}

let row_start = screen_y * buf_width;

for sx in 0..area_width {
let screen_x = area_x + sx;
if screen_x >= buf_width {
break;
}

// Map screen pixel to image coordinates
let img_x_f = (sx as f64 - center_x) / scale + off_x;
let img_y_f = (sy as f64 - center_y) / scale + off_y;

// Checkerboard for background
let checker_col = (sx / cell) & 1;
let checker_row = (sy / cell) & 1;
let bg = if (checker_col ^ checker_row) == 0 {
light
} else {
dark
};

let pixel_idx = row_start + screen_x;

// Check if this screen pixel maps to a valid image pixel
let img_x = img_x_f as i64;
let img_y = img_y_f as i64;

if img_x >= 0
&& img_y >= 0
&& (img_x as u32) < img_w
&& (img_y as u32) < img_h
{
let src_idx = (img_y as usize * img_w as usize + img_x as usize) * 4;

if src_idx + 3 < image.pixels.len() {
let r = image.pixels[src_idx] as u32;
let g = image.pixels[src_idx + 1] as u32;
let b = image.pixels[src_idx + 2] as u32;
let a = image.pixels[src_idx + 3] as u32;

if a == 255 {
buffer[pixel_idx] = 0xFF000000 | (r << 16) | (g << 8) | b;
} else if a == 0 {
buffer[pixel_idx] = bg;
} else {
// Alpha blend over checkerboard
let inv_a = 255 - a;
let bg_r = (bg >> 16) & 0xFF;
let bg_g = (bg >> 8) & 0xFF;
let bg_b = bg & 0xFF;
let out_r = (r * a + bg_r * inv_a) / 255;
let out_g = (g * a + bg_g * inv_a) / 255;
let out_b = (b * a + bg_b * inv_a) / 255;
buffer[pixel_idx] = 0xFF000000 | (out_r << 16) | (out_g << 8) | out_b;
}
} else {
buffer[pixel_idx] = bg;
}
} else {
buffer[pixel_idx] = bg;
}
}
}
}
Loading