diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..c91c3f3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[net] +git-fetch-with-cli = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca24cdc..c3704b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69e2260..33340a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: | @@ -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: @@ -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: diff --git a/dist-workspace.toml b/dist-workspace.toml index 134fca3..5a62e2b 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -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 = "*" diff --git a/keymap.yaml b/keymap.yaml index 8b76fbc..a07f334 100644 --- a/keymap.yaml +++ b/keymap.yaml @@ -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 diff --git a/src/image/mod.rs b/src/image/mod.rs new file mode 100644 index 0000000..a8c0343 --- /dev/null +++ b/src/image/mod.rs @@ -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, + /// 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, +} + +/// 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, + 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 { + 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, + )) +} diff --git a/src/image/render.rs b/src/image/render.rs new file mode 100644 index 0000000..4f9e4ae --- /dev/null +++ b/src/image/render.rs @@ -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; + } + } + } +} diff --git a/src/keymap/command.rs b/src/keymap/command.rs index 45d0b52..6ca661f 100644 --- a/src/keymap/command.rs +++ b/src/keymap/command.rs @@ -4,8 +4,8 @@ //! Each command maps to one or more `Msg` values for the Elm-style update loop. use crate::messages::{ - AppMsg, CsvMsg, Direction, DockMsg, DocumentMsg, EditorMsg, LayoutMsg, Msg, PreviewMsg, UiMsg, - WorkspaceMsg, + AppMsg, CsvMsg, Direction, DockMsg, DocumentMsg, EditorMsg, ImageMsg, LayoutMsg, Msg, + PreviewMsg, UiMsg, WorkspaceMsg, }; use crate::model::editor_area::SplitDirection; use crate::model::ModalId; @@ -263,6 +263,16 @@ pub enum Command { CsvPageUp, CsvPageDown, CsvExit, + + // Image viewer + /// Zoom in (image mode) + ImageZoomIn, + /// Zoom out (image mode) + ImageZoomOut, + /// Fit image to window + ImageFitToWindow, + /// Show image at actual size (1:1) + ImageActualSize, } impl Command { @@ -460,6 +470,20 @@ impl Command { CsvPageUp => vec![Msg::Csv(CsvMsg::PageUp)], CsvPageDown => vec![Msg::Csv(CsvMsg::PageDown)], CsvExit => vec![Msg::Csv(CsvMsg::Exit)], + + // Image viewer + ImageZoomIn => vec![Msg::Image(ImageMsg::Zoom { + delta: 1.0, + mouse_x: 0.0, + mouse_y: 0.0, + })], + ImageZoomOut => vec![Msg::Image(ImageMsg::Zoom { + delta: -1.0, + mouse_x: 0.0, + mouse_y: 0.0, + })], + ImageFitToWindow => vec![Msg::Image(ImageMsg::FitToWindow)], + ImageActualSize => vec![Msg::Image(ImageMsg::ActualSize)], } } @@ -615,6 +639,11 @@ impl Command { CsvPageUp => "CSV Page Up", CsvPageDown => "CSV Page Down", CsvExit => "Exit CSV View", + + ImageZoomIn => "Image: Zoom In", + ImageZoomOut => "Image: Zoom Out", + ImageFitToWindow => "Image: Fit to Window", + ImageActualSize => "Image: Actual Size", } } } diff --git a/src/keymap/config.rs b/src/keymap/config.rs index 955083b..4be6fcd 100644 --- a/src/keymap/config.rs +++ b/src/keymap/config.rs @@ -367,6 +367,12 @@ impl FromStr for Command { "MarkdownTogglePreview" => Ok(Command::MarkdownTogglePreview), "MarkdownOpenPreviewToSide" => Ok(Command::MarkdownOpenPreviewToSide), + // Image viewer + "ImageZoomIn" => Ok(Command::ImageZoomIn), + "ImageZoomOut" => Ok(Command::ImageZoomOut), + "ImageFitToWindow" => Ok(Command::ImageFitToWindow), + "ImageActualSize" => Ok(Command::ImageActualSize), + // Special "EscapeSmartClear" => Ok(Command::EscapeSmartClear), "Unbound" => Ok(Command::Unbound), diff --git a/src/lib.rs b/src/lib.rs index e1e11ec..888d0fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod csv; pub mod debug_overlay; pub mod editable; pub mod fs_watcher; +pub mod image; pub mod keymap; pub mod markdown; pub mod messages; diff --git a/src/messages.rs b/src/messages.rs index 023e934..ba87a52 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -650,6 +650,27 @@ pub enum WorkspaceMsg { FileSystemChange { paths: Vec }, } +/// Image viewer messages +#[derive(Debug, Clone)] +pub enum ImageMsg { + /// Zoom by delta at mouse position (scroll wheel or keyboard) + Zoom { delta: f64, mouse_x: f64, mouse_y: f64 }, + /// Start panning (mouse down) + StartPan { x: f64, y: f64 }, + /// Update pan position (mouse drag) + UpdatePan { x: f64, y: f64 }, + /// End panning (mouse up) + EndPan, + /// Fit image to window + FitToWindow, + /// Show image at actual size (1:1) + ActualSize, + /// Track mouse position for zoom-toward-cursor + MouseMove { x: f64, y: f64 }, + /// Viewport was resized + ViewportResized { width: u32, height: u32 }, +} + /// Top-level message type #[derive(Debug, Clone)] pub enum Msg { @@ -667,6 +688,8 @@ pub enum Msg { Syntax(SyntaxMsg), /// CSV mode messages Csv(CsvMsg), + /// Image viewer messages + Image(ImageMsg), /// Markdown preview messages Preview(PreviewMsg), /// Workspace messages (file tree) diff --git a/src/model/editor.rs b/src/model/editor.rs index 267f7f3..966ab7b 100644 --- a/src/model/editor.rs +++ b/src/model/editor.rs @@ -265,33 +265,16 @@ pub struct OccurrenceState { pub last_search_offset: usize, } -/// View mode for the editor -/// /// What kind of content this tab displays #[derive(Debug, Clone, Default)] pub enum TabContent { /// Normal text/code editing (uses Document rope + ViewMode) #[default] Text, - /// Image viewer (decoded RGBA pixels) - Image(ImageTabState), /// Placeholder for unsupported binary files BinaryPlaceholder(BinaryPlaceholderState), } -/// State for an image viewer tab -#[derive(Debug, Clone)] -pub struct ImageTabState { - /// Path to the image file - pub path: std::path::PathBuf, - /// Decoded RGBA8 pixel data - pub pixels: Vec, - /// Image width in pixels - pub width: u32, - /// Image height in pixels - pub height: u32, -} - /// State for a binary file placeholder tab #[derive(Debug, Clone)] pub struct BinaryPlaceholderState { @@ -310,6 +293,8 @@ pub enum ViewMode { Text, /// CSV spreadsheet view mode Csv(Box), + /// Image viewer mode + Image(Box), } impl ViewMode { @@ -333,6 +318,27 @@ impl ViewMode { _ => None, } } + + /// Check if in image mode + pub fn is_image(&self) -> bool { + matches!(self, ViewMode::Image(_)) + } + + /// Get image state if in image mode + pub fn as_image(&self) -> Option<&crate::image::ImageState> { + match self { + ViewMode::Image(state) => Some(state), + _ => None, + } + } + + /// Get mutable image state if in image mode + pub fn as_image_mut(&mut self) -> Option<&mut crate::image::ImageState> { + match self { + ViewMode::Image(state) => Some(state), + _ => None, + } + } } /// Editor state - view-specific state for editing a document diff --git a/src/model/mod.rs b/src/model/mod.rs index dfe07e7..13e7336 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -11,7 +11,7 @@ pub mod workspace; pub use document::{Document, EditOperation}; pub use editor::{ - BinaryPlaceholderState, Cursor, EditorState, ImageTabState, OccurrenceState, Position, + BinaryPlaceholderState, Cursor, EditorState, OccurrenceState, Position, RectangleSelectionState, ScrollRevealMode, Selection, TabContent, ViewMode, Viewport, }; pub use editor_area::{ diff --git a/src/model/status_bar.rs b/src/model/status_bar.rs index 21509cb..a26a9e4 100644 --- a/src/model/status_bar.rs +++ b/src/model/status_bar.rs @@ -359,6 +359,38 @@ use super::AppModel; /// Synchronize status bar segments with current document/editor state pub fn sync_status_bar(model: &mut AppModel) { + // Image mode: show image-specific info in status bar + if let Some(image_state) = model.editor_area.focused_editor() + .and_then(|e| e.view_mode.as_image()) + { + let dims = format!("{}x{}", image_state.width, image_state.height); + let zoom = format!("{}%", image_state.zoom_percent()); + let file_size = image_state.file_size_display(); + let format = image_state.format.clone(); + + model.ui.status_bar.update_segment( + SegmentId::CursorPosition, + SegmentContent::Text(dims), + ); + model.ui.status_bar.update_segment( + SegmentId::LineCount, + SegmentContent::Text(zoom), + ); + model.ui.status_bar.update_segment( + SegmentId::Selection, + SegmentContent::Text(file_size), + ); + model.ui.status_bar.update_segment( + SegmentId::CaretCount, + SegmentContent::Text(format), + ); + model.ui.status_bar.update_segment( + SegmentId::ModifiedIndicator, + SegmentContent::Empty, + ); + return; + } + // FileName segment let filename = model .document() diff --git a/src/runtime/app.rs b/src/runtime/app.rs index 9fc6ae6..35854a4 100644 --- a/src/runtime/app.rs +++ b/src/runtime/app.rs @@ -20,7 +20,7 @@ use token::fs_watcher::{FileSystemEvent, FileSystemWatcher}; use token::keymap::{ keystroke_from_winit, load_default_keymap, Command, KeyAction, KeyContext, Keymap, }; -use token::messages::{AppMsg, CsvMsg, EditorMsg, LayoutMsg, Msg, SyntaxMsg, UiMsg, WorkspaceMsg}; +use token::messages::{AppMsg, CsvMsg, EditorMsg, ImageMsg, LayoutMsg, Msg, SyntaxMsg, UiMsg, WorkspaceMsg}; use token::model::editor::Position; use token::model::editor_area::{Rect, SplitDirection}; use token::model::AppModel; @@ -652,6 +652,35 @@ impl App { ); } + // Handle image panning and mouse tracking + if let Some(editor) = self.model.editor_area.focused_editor() { + if editor.view_mode.is_image() { + let has_drag = editor.view_mode.as_image() + .map(|img| img.drag.is_some()) + .unwrap_or(false); + + if self.left_mouse_down && has_drag { + update( + &mut self.model, + Msg::Image(ImageMsg::UpdatePan { + x: position.x, + y: position.y, + }), + ); + } + + update( + &mut self.model, + Msg::Image(ImageMsg::MouseMove { + x: position.x, + y: position.y, + }), + ); + + return Some(Cmd::Redraw); + } + } + if self.model.editor().rectangle_selection.active { if let Some(renderer) = &mut self.renderer { // Use visual column (screen position) for rectangle selection @@ -758,6 +787,16 @@ impl App { Msg::Workspace(WorkspaceMsg::EndSidebarResize), ); } + + // End image pan if active + if let Some(editor) = self.model.editor_area.focused_editor() { + if editor.view_mode.as_image() + .map(|img| img.drag.is_some()) + .unwrap_or(false) + { + return update(&mut self.model, Msg::Image(ImageMsg::EndPan)); + } + } None } WindowEvent::MouseInput { @@ -867,8 +906,31 @@ impl App { | HoverRegion::Button | HoverRegion::None => None, - // Editor text area: scroll the editor (or CSV if in CSV mode) + // Editor text area: scroll the editor (or CSV/Image if in those modes) HoverRegion::EditorText => { + // Check if focused editor is in image mode + let in_image_mode = self + .model + .editor_area + .focused_editor() + .map(|e| e.view_mode.is_image()) + .unwrap_or(false); + + if in_image_mode { + if v_delta != 0 { + let (mouse_x, mouse_y) = self.mouse_position.unwrap_or((0.0, 0.0)); + return update( + &mut self.model, + Msg::Image(ImageMsg::Zoom { + delta: v_delta as f64, + mouse_x, + mouse_y, + }), + ); + } + return None; + } + // Check if focused editor is in CSV mode let in_csv_mode = self .model diff --git a/src/runtime/mouse.rs b/src/runtime/mouse.rs index fe4729d..134c0e6 100644 --- a/src/runtime/mouse.rs +++ b/src/runtime/mouse.rs @@ -12,7 +12,7 @@ use winit::event::{ElementState, MouseButton}; use winit::keyboard::ModifiersState; use token::commands::Cmd; -use token::messages::{LayoutMsg, ModalMsg, Msg, PreviewMsg, UiMsg, WorkspaceMsg}; +use token::messages::{ImageMsg, LayoutMsg, ModalMsg, Msg, PreviewMsg, UiMsg, WorkspaceMsg}; use token::model::AppModel; use token::update::update; @@ -123,7 +123,7 @@ pub fn handle_mouse_press( // Track if we're clicking on editor content (for drag tracking) let is_editor_content = matches!( target, - HitTarget::EditorContent { .. } | HitTarget::EditorGutter { .. } + HitTarget::EditorContent { .. } | HitTarget::EditorGutter { .. } | HitTarget::ImageContent { .. } ); let is_left_click = matches!(event.button, MouseButton::Left); @@ -345,6 +345,21 @@ fn handle_left_click( EventResult::consumed_with_focus(FocusTarget::Editor) } + // Image content - start panning + HitTarget::ImageContent { group_id, .. } => { + if *group_id != model.editor_area.focused_group_id { + update(model, Msg::Layout(LayoutMsg::FocusGroup(*group_id))); + } + update( + model, + Msg::Image(ImageMsg::StartPan { + x: event.pos.x, + y: event.pos.y, + }), + ); + EventResult::consumed_with_focus(FocusTarget::Editor) + } + // Binary placeholder "Open with Default Application" button HitTarget::BinaryPlaceholderButton { group_id } => { if *group_id != model.editor_area.focused_group_id { @@ -481,9 +496,6 @@ fn handle_editor_content_click( } return EventResult::consumed_with_focus(FocusTarget::Editor); } - token::model::TabContent::Image(_) => { - return EventResult::consumed_with_focus(FocusTarget::Editor); - } token::model::TabContent::Text => {} } } @@ -629,6 +641,9 @@ fn handle_middle_click( // Binary placeholder button - no middle-click action HitTarget::BinaryPlaceholderButton { .. } => EventResult::consumed_no_redraw(), + + // Image content - no middle-click action + HitTarget::ImageContent { .. } => EventResult::consumed_no_redraw(), } } diff --git a/src/update/app.rs b/src/update/app.rs index b2e8323..6bc6629 100644 --- a/src/update/app.rs +++ b/src/update/app.rs @@ -33,6 +33,15 @@ pub fn update_app(model: &mut AppModel, msg: AppMsg) -> Option { .saturating_sub(col_header_height); let visible_rows = content_height / line_height; csv.set_viewport_size(visible_rows.max(1), csv.viewport.visible_cols); + } else if let Some(image) = editor.view_mode.as_image_mut() { + if !image.user_zoomed { + image.scale = crate::image::ImageState::compute_fit_scale( + image.width, + image.height, + width, + height, + ); + } } } diff --git a/src/update/image.rs b/src/update/image.rs new file mode 100644 index 0000000..59fd272 --- /dev/null +++ b/src/update/image.rs @@ -0,0 +1,172 @@ +//! Image viewer update handlers +//! +//! Processes ImageMsg messages to update pan/zoom state. + +use crate::commands::Cmd; +use crate::messages::ImageMsg; +use crate::model::AppModel; + +/// Minimum zoom level (10%) +const MIN_SCALE: f64 = 0.1; +/// Maximum zoom level (1000%) +const MAX_SCALE: f64 = 10.0; +/// Zoom sensitivity per scroll tick +const ZOOM_FACTOR: f64 = 0.1; + +pub fn update_image(model: &mut AppModel, msg: ImageMsg) -> Option { + let editor_id = model.editor_area.focused_editor_id()?; + + match msg { + ImageMsg::Zoom { + delta, + mouse_x, + mouse_y, + } => { + // Get content area origin (group rect + tab bar offset) + let group_id = model.editor_area.focused_group_id; + let group = model.editor_area.groups.get(&group_id)?; + let area_x = group.rect.x as f64; + let area_y = group.rect.y as f64 + model.metrics.tab_bar_height as f64; + + let area_w = group.rect.width as f64; + let area_h = group.rect.height as f64 - model.metrics.tab_bar_height as f64; + + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + + // For keyboard zoom (0,0), anchor at last known mouse position + let raw_x = if mouse_x == 0.0 && mouse_y == 0.0 { + state.last_mouse_x + } else { + mouse_x + }; + let raw_y = if mouse_x == 0.0 && mouse_y == 0.0 { + state.last_mouse_y + } else { + mouse_y + }; + + // Convert window coords to content-area-local coords + let local_x = raw_x - area_x; + let local_y = raw_y - area_y; + + // Account for centering offset (matches render.rs logic) + let scaled_w = state.width as f64 * state.scale; + let scaled_h = state.height as f64 * state.scale; + let center_x = if scaled_w < area_w { (area_w - scaled_w) / 2.0 } else { 0.0 }; + let center_y = if scaled_h < area_h { (area_h - scaled_h) / 2.0 } else { 0.0 }; + + let anchor_x = local_x - center_x; + let anchor_y = local_y - center_y; + + // Compute the image-space point under the cursor before zoom + let img_x = state.offset_x + anchor_x / state.scale; + let img_y = state.offset_y + anchor_y / state.scale; + + // Apply zoom + let factor = 1.0 + delta * ZOOM_FACTOR; + let new_scale = (state.scale * factor).clamp(MIN_SCALE, MAX_SCALE); + state.scale = new_scale; + + // Recompute centering for the new scale + let new_scaled_w = state.width as f64 * new_scale; + let new_scaled_h = state.height as f64 * new_scale; + let new_center_x = if new_scaled_w < area_w { (area_w - new_scaled_w) / 2.0 } else { 0.0 }; + let new_center_y = if new_scaled_h < area_h { (area_h - new_scaled_h) / 2.0 } else { 0.0 }; + + // Adjust offset so the anchor point stays stationary + let new_anchor_x = local_x - new_center_x; + let new_anchor_y = local_y - new_center_y; + state.offset_x = img_x - new_anchor_x / new_scale; + state.offset_y = img_y - new_anchor_y / new_scale; + + state.user_zoomed = true; + Some(Cmd::redraw_editor()) + } + + ImageMsg::StartPan { x, y } => { + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + + state.drag = Some(crate::image::DragState { + start_mouse_x: x, + start_mouse_y: y, + start_offset_x: state.offset_x, + start_offset_y: state.offset_y, + }); + Some(Cmd::redraw_editor()) + } + + ImageMsg::UpdatePan { x, y } => { + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + + if let Some(drag) = &state.drag { + let dx = (x - drag.start_mouse_x) / state.scale; + let dy = (y - drag.start_mouse_y) / state.scale; + state.offset_x = drag.start_offset_x - dx; + state.offset_y = drag.start_offset_y - dy; + } + Some(Cmd::redraw_editor()) + } + + ImageMsg::EndPan => { + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + state.drag = None; + Some(Cmd::redraw_editor()) + } + + ImageMsg::FitToWindow => { + let group_id = model.editor_area.focused_group_id; + let group = model.editor_area.groups.get(&group_id)?; + let vw = group.rect.width as u32; + let vh = (group.rect.height as usize) + .saturating_sub(model.metrics.tab_bar_height) as u32; + + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + state.scale = + crate::image::ImageState::compute_fit_scale(state.width, state.height, vw, vh); + state.offset_x = 0.0; + state.offset_y = 0.0; + state.user_zoomed = false; + Some(Cmd::redraw_editor()) + } + + ImageMsg::ActualSize => { + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + state.scale = 1.0; + state.offset_x = 0.0; + state.offset_y = 0.0; + state.user_zoomed = true; + Some(Cmd::redraw_editor()) + } + + ImageMsg::MouseMove { x, y } => { + // Store raw window coords (zoom handler does its own conversion) + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + state.last_mouse_x = x; + state.last_mouse_y = y; + None + } + + ImageMsg::ViewportResized { width, height } => { + let editor = model.editor_area.editors.get_mut(&editor_id)?; + let state = editor.view_mode.as_image_mut()?; + if !state.user_zoomed { + state.scale = crate::image::ImageState::compute_fit_scale( + state.width, + state.height, + width, + height, + ); + state.offset_x = 0.0; + state.offset_y = 0.0; + } + Some(Cmd::redraw_editor()) + } + } +} diff --git a/src/update/layout.rs b/src/update/layout.rs index 7a949e7..90788de 100644 --- a/src/update/layout.rs +++ b/src/update/layout.rs @@ -9,7 +9,7 @@ use crate::model::{ AppModel, Document, EditorGroup, EditorState, GroupId, LayoutNode, Rect, SplitContainer, SplitDirection, Tab, TabId, }; -use crate::model::editor::{BinaryPlaceholderState, ImageTabState, TabContent}; +use crate::model::editor::{BinaryPlaceholderState, TabContent, ViewMode}; use crate::util::{ filename_for_display, is_likely_binary, is_supported_image, validate_file_for_opening, FileOpenError, @@ -241,55 +241,56 @@ fn open_file_in_new_tab(model: &mut AppModel, path: PathBuf) -> Option { Ok(()) => { // File exists - check for image files first if is_supported_image(&path) { - // TODO: image::open() blocks the main thread during decode. For large images or slow - // drives this freezes the UI. Fix: add Cmd::LoadImage to spawn decode on a background - // thread, post Msg::ImageLoaded back via EventLoopProxy, and show a loading state. - let img = match image::open(&path) { - Ok(img) => img.to_rgba8(), - Err(e) => { - model - .ui - .set_status(format!("Error opening image {}: {}", filename, e)); + let group = model.editor_area.groups.get(&group_id); + let vw = group.map(|g| g.rect.width as u32).unwrap_or(800); + let vh = group + .map(|g| (g.rect.height as usize).saturating_sub(model.metrics.tab_bar_height) as u32) + .unwrap_or(600); + + match crate::image::load_image(&path, vw, vh) { + Some(image_state) => { + let w = image_state.width; + let h = image_state.height; + + let mut doc = Document::new(); + doc.id = Some(doc_id); + doc.file_path = Some(path.clone()); + model.editor_area.documents.insert(doc_id, doc); + model.record_file_opened(path.clone()); + + let editor_id = model.editor_area.next_editor_id(); + let mut editor = EditorState::new(); + editor.id = Some(editor_id); + editor.document_id = Some(doc_id); + editor.view_mode = ViewMode::Image(Box::new(image_state)); + model.editor_area.editors.insert(editor_id, editor); + + let tab_id = model.editor_area.next_tab_id(); + let tab = Tab { + id: tab_id, + editor_id, + is_pinned: false, + is_preview: false, + }; + if let Some(group) = model.editor_area.groups.get_mut(&group_id) { + group.tabs.push(tab); + group.active_tab_index = group.tabs.len() - 1; + } + + model.ui.set_status(format!( + "Opened image: {} ({}×{})", + filename, w, h + )); + return Some(Cmd::Redraw); + } + None => { + model.ui.set_status(format!( + "Error opening image: {}", + filename + )); return Some(Cmd::Redraw); } - }; - let (width, height) = img.dimensions(); - let pixels = img.into_raw(); - - let mut doc = Document::new(); - doc.id = Some(doc_id); - doc.file_path = Some(path.clone()); - model.editor_area.documents.insert(doc_id, doc); - model.record_file_opened(path.clone()); - - let editor_id = model.editor_area.next_editor_id(); - let mut editor = EditorState::new(); - editor.id = Some(editor_id); - editor.document_id = Some(doc_id); - editor.tab_content = TabContent::Image(ImageTabState { - path, - pixels, - width, - height, - }); - model.editor_area.editors.insert(editor_id, editor); - - let tab_id = model.editor_area.next_tab_id(); - let tab = Tab { - id: tab_id, - editor_id, - is_pinned: false, - is_preview: false, - }; - if let Some(group) = model.editor_area.groups.get_mut(&group_id) { - group.tabs.push(tab); - group.active_tab_index = group.tabs.len() - 1; } - - model - .ui - .set_status(format!("Opened image: {} ({}×{})", filename, width, height)); - return Some(Cmd::Redraw); } // Check for binary content diff --git a/src/update/mod.rs b/src/update/mod.rs index 1a7ae31..3f5349d 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -7,6 +7,7 @@ mod csv; mod dock; mod document; mod editor; +mod image; pub mod layout; mod outline; mod preview; @@ -58,6 +59,15 @@ pub fn update(model: &mut AppModel, msg: Msg) -> Option { fn update_inner(model: &mut AppModel, msg: Msg) -> Option { let result = match msg { Msg::Editor(m) => { + // Block editor messages in image mode and binary placeholder mode + let is_non_text = model.editor_area.focused_editor().is_some_and(|e| { + e.view_mode.is_image() + || matches!(e.tab_content, crate::model::editor::TabContent::BinaryPlaceholder(_)) + }); + if is_non_text { + return None; + } + // When in CSV mode, intercept navigation messages and route to CSV let csv_info = model .editor_area @@ -68,12 +78,20 @@ fn update_inner(model: &mut AppModel, msg: Msg) -> Option { if let Some(csv_msg) = map_editor_to_csv(&m, is_editing) { return csv::update_csv(model, csv_msg); } - // For other editor messages in CSV mode, ignore them return None; } editor::update_editor(model, m) } Msg::Document(m) => { + // Block document messages in image mode and binary placeholder mode + let is_non_text = model.editor_area.focused_editor().is_some_and(|e| { + e.view_mode.is_image() + || matches!(e.tab_content, crate::model::editor::TabContent::BinaryPlaceholder(_)) + }); + if is_non_text { + return None; + } + // When in CSV mode, intercept document messages for cell editing let csv_info = model .editor_area @@ -84,7 +102,6 @@ fn update_inner(model: &mut AppModel, msg: Msg) -> Option { if let Some(csv_msg) = map_document_to_csv(&m, is_editing) { return csv::update_csv(model, csv_msg); } - // Block other document messages in CSV mode return None; } document::update_document(model, m) @@ -94,6 +111,7 @@ fn update_inner(model: &mut AppModel, msg: Msg) -> Option { Msg::App(m) => app::update_app(model, m), Msg::Syntax(m) => syntax::update_syntax(model, m), Msg::Csv(m) => csv::update_csv(model, m), + Msg::Image(m) => image::update_image(model, m), Msg::Preview(m) => preview::update_preview(model, m), Msg::Workspace(m) => workspace::update_workspace(model, m), Msg::Dock(m) => dock::update_dock(model, m), @@ -219,6 +237,7 @@ fn msg_type_name(msg: &Msg) -> String { Msg::App(m) => format!("App::{:?}", m), Msg::Syntax(m) => format!("Syntax::{:?}", m), Msg::Csv(m) => format!("Csv::{:?}", m), + Msg::Image(m) => format!("Image::{:?}", m), Msg::Preview(m) => format!("Preview::{:?}", m), Msg::Workspace(m) => format!("Workspace::{:?}", m), Msg::Dock(m) => format!("Dock::{:?}", m), diff --git a/src/view/hit_test.rs b/src/view/hit_test.rs index ab0b211..e80cc3a 100644 --- a/src/view/hit_test.rs +++ b/src/view/hit_test.rs @@ -209,6 +209,12 @@ pub enum HitTarget { /// "Open with Default Application" button on binary placeholder tab BinaryPlaceholderButton { group_id: GroupId }, + + /// Image content area (pan/zoom viewer) + ImageContent { + group_id: GroupId, + editor_id: EditorId, + }, } impl HitTarget { @@ -221,7 +227,8 @@ impl HitTarget { | HitTarget::EditorGutter { group_id, .. } | HitTarget::EditorContent { group_id, .. } | HitTarget::CsvCell { group_id, .. } - | HitTarget::BinaryPlaceholderButton { group_id } => Some(*group_id), + | HitTarget::BinaryPlaceholderButton { group_id } + | HitTarget::ImageContent { group_id, .. } => Some(*group_id), _ => None, } } @@ -237,7 +244,8 @@ impl HitTarget { | HitTarget::EditorGutter { .. } | HitTarget::EditorContent { .. } | HitTarget::CsvCell { .. } - | HitTarget::BinaryPlaceholderButton { .. } => Some(FocusTarget::Editor), + | HitTarget::BinaryPlaceholderButton { .. } + | HitTarget::ImageContent { .. } => Some(FocusTarget::Editor), // Dock content areas suggest sidebar focus for left dock (file explorer), // editor focus for others (until we have FocusTarget::Dock) HitTarget::DockTab { position, .. } @@ -557,6 +565,14 @@ pub fn hit_test_groups(model: &AppModel, pt: Point, char_width: f32) -> Option { - Self::render_image_tab(frame, painter, model, img_state, &layout); - } - crate::model::editor::TabContent::BinaryPlaceholder(placeholder) => { + // Dispatch based on tab content and view mode + if matches!(editor.tab_content, crate::model::editor::TabContent::BinaryPlaceholder(_)) { + if let crate::model::editor::TabContent::BinaryPlaceholder(ref placeholder) = editor.tab_content { Self::render_binary_placeholder(frame, painter, model, placeholder, &layout); } - crate::model::editor::TabContent::Text => { - if let Some(csv_state) = editor.view_mode.as_csv() { - Self::render_csv_grid(frame, painter, model, csv_state, &layout, is_focused); - } else { - Self::render_text_area( - frame, painter, model, editor, document, &layout, is_focused, - ); - Self::render_gutter(frame, painter, model, editor, document, &layout); - } - } + } else if let Some(image_state) = editor.view_mode.as_image() { + let cr = &layout.content_rect; + crate::image::render::render_image( + frame, + image_state, + &model.theme.image_preview, + cr.x as usize, + cr.y as usize, + cr.width as usize, + cr.height as usize, + ); + } else if let Some(csv_state) = editor.view_mode.as_csv() { + Self::render_csv_grid(frame, painter, model, csv_state, &layout, is_focused); + } else { + Self::render_text_area( + frame, painter, model, editor, document, &layout, is_focused, + ); + Self::render_gutter(frame, painter, model, editor, document, &layout); } // Dim non-focused groups when multiple groups exist (4% black overlay) @@ -950,51 +955,6 @@ impl Renderer { } } - /// Render an image viewer tab - fn render_image_tab( - frame: &mut Frame, - _painter: &mut TextPainter, - model: &AppModel, - img_state: &crate::model::editor::ImageTabState, - layout: &geometry::GroupLayout, - ) { - let content_rect = layout.content_rect; - let bg = model.theme.editor.background.to_argb_u32(); - frame.fill_rect(content_rect, bg); - - let padding = model.metrics.padding_large * 2; - let dest_x = content_rect.x as usize + padding; - let dest_y = content_rect.y as usize + padding; - let dest_w = (content_rect.width as usize).saturating_sub(padding * 2); - let dest_h = (content_rect.height as usize).saturating_sub(padding * 2); - - if dest_w > 0 && dest_h > 0 { - // Draw checkerboard pattern for transparency - let ip = &model.theme.image_preview; - let check_size = ip.checkerboard_size; - let light = ip.checkerboard_light.to_argb_u32(); - let dark = ip.checkerboard_dark.to_argb_u32(); - for cy in 0..dest_h { - for cx in 0..dest_w { - let px = dest_x + cx; - let py = dest_y + cy; - let checker = ((cx / check_size) + (cy / check_size)).is_multiple_of(2); - frame.set_pixel(px, py, if checker { light } else { dark }); - } - } - - frame.blit_rgba_scaled( - &img_state.pixels, - img_state.width, - img_state.height, - dest_x, - dest_y, - dest_w, - dest_h, - ); - } - } - /// Render a binary file placeholder tab fn render_binary_placeholder( frame: &mut Frame, diff --git a/tests/image_viewer.rs b/tests/image_viewer.rs new file mode 100644 index 0000000..daf4f25 --- /dev/null +++ b/tests/image_viewer.rs @@ -0,0 +1,97 @@ +use std::path::Path; +use token::image::ImageState; +use token::util::is_supported_image; + +#[test] +fn test_image_file_detection() { + assert!(is_supported_image(Path::new("test.png"))); + assert!(is_supported_image(Path::new("test.jpg"))); + assert!(is_supported_image(Path::new("test.jpeg"))); + assert!(is_supported_image(Path::new("test.gif"))); + assert!(is_supported_image(Path::new("test.bmp"))); + assert!(is_supported_image(Path::new("test.webp"))); + assert!(is_supported_image(Path::new("test.ico"))); + assert!(!is_supported_image(Path::new("test.rs"))); + assert!(!is_supported_image(Path::new("test.txt"))); +} + +#[test] +fn test_compute_fit_scale_large_image() { + // Image larger than viewport should be scaled down + let scale = ImageState::compute_fit_scale(1920, 1080, 800, 600); + assert!(scale < 1.0); + // Should fit width: 800/1920 ≈ 0.4167 + assert!((scale - 800.0 / 1920.0).abs() < 0.01); +} + +#[test] +fn test_compute_fit_scale_small_image() { + // Image smaller than viewport should stay at 1.0 + let scale = ImageState::compute_fit_scale(100, 100, 800, 600); + assert!((scale - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn test_compute_fit_scale_zero_viewport() { + let scale = ImageState::compute_fit_scale(100, 100, 0, 0); + assert!((scale - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn test_compute_fit_scale_zero_dimension_image() { + let scale = ImageState::compute_fit_scale(0, 100, 800, 600); + assert!((scale - 1.0).abs() < f64::EPSILON); + + let scale = ImageState::compute_fit_scale(100, 0, 800, 600); + assert!((scale - 1.0).abs() < f64::EPSILON); + + let scale = ImageState::compute_fit_scale(0, 0, 800, 600); + assert!((scale - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn test_compute_fit_scale_tall_image() { + // Tall image should fit by height + let scale = ImageState::compute_fit_scale(100, 2000, 800, 600); + assert!((scale - 600.0 / 2000.0).abs() < 0.01); +} + +#[test] +fn test_file_size_display() { + let state = ImageState::new(vec![0; 400], 10, 10, 2_500_000, "PNG".into(), 800, 600); + assert_eq!(state.file_size_display(), "2.4 MB"); + + let state = ImageState::new(vec![0; 400], 10, 10, 150_000, "PNG".into(), 800, 600); + assert_eq!(state.file_size_display(), "146 KB"); + + let state = ImageState::new(vec![0; 400], 10, 10, 500, "PNG".into(), 800, 600); + assert_eq!(state.file_size_display(), "500 B"); +} + +#[test] +fn test_zoom_percent() { + let mut state = ImageState::new(vec![0; 400], 10, 10, 100, "PNG".into(), 800, 600); + state.scale = 1.0; + assert_eq!(state.zoom_percent(), 100); + state.scale = 0.5; + assert_eq!(state.zoom_percent(), 50); + state.scale = 2.0; + assert_eq!(state.zoom_percent(), 200); +} + +#[test] +fn test_image_state_auto_fit() { + // Large image should auto-fit below 1.0 + let state = ImageState::new(vec![0; 16000], 2000, 1000, 16000, "PNG".into(), 800, 600); + assert!(state.scale < 1.0); + assert!(!state.user_zoomed); + assert_eq!(state.offset_x, 0.0); + assert_eq!(state.offset_y, 0.0); +} + +#[test] +fn test_image_state_no_upscale() { + // Small image should not be scaled up + let state = ImageState::new(vec![0; 400], 10, 10, 400, "PNG".into(), 800, 600); + assert!((state.scale - 1.0).abs() < f64::EPSILON); +}