Skip to content

feat(viewer): add image preview via chafa#55

Merged
leszek3737 merged 2 commits into
mainfrom
image
May 23, 2026
Merged

feat(viewer): add image preview via chafa#55
leszek3737 merged 2 commits into
mainfrom
image

Conversation

@leszek3737

@leszek3737 leszek3737 commented May 23, 2026

Copy link
Copy Markdown
Owner

Renders image files as ANSI character art in the internal viewer (F3). Auto-detects image/* MIME types and switches to Image mode. Toggle between image and hex mode with h.

  • Spawn chafa --size WxH in event loop, cache parsed ANSI Text
  • Remove RefCell from cache fields -- render path is pure read-only
  • Add is_image_mime() helper, -- safety flag for chafa invocation
  • toggle_wrap / toggle_line_numbers no-op in Image mode
  • Show placeholder while chafa runs, error if chafa missing
  • Add 6 tests for Image mode, 957 total passing

Summary by Sourcery

Add image preview support to the built-in viewer using chafa and integrate it into the viewing workflow.

New Features:

  • Introduce an Image view mode that auto-activates for image MIME types and can be toggled with hex view.
  • Add image preview rendering via chafa with cached ANSI output sized to the terminal viewport.

Enhancements:

  • Extend viewer rendering pipeline to handle text, hex, and image modes distinctly.
  • Refactor terminal startup to recover any previous terminal state through a dedicated helper.
  • Cache generated image previews by terminal size to avoid redundant chafa invocations.

Build:

  • Add ansi-to-tui as a dependency for parsing ANSI sequences used in image viewing.

Documentation:

  • Document image preview capabilities, requirements, controls, and internals in the README.

Tests:

  • Add tests covering image mode activation, mode toggling behavior, viewer controls no-ops in image mode, and non-panicking rendering for image files.

Renders image files as ANSI character art in the internal viewer (F3).
Auto-detects image/* MIME types and switches to Image mode. Toggle
between image and hex mode with h.

- Spawn chafa --size WxH in event loop, cache parsed ANSI Text
- Remove RefCell from cache fields -- render path is pure read-only
- Add is_image_mime() helper, -- safety flag for chafa invocation
- toggle_wrap / toggle_line_numbers no-op in Image mode
- Show placeholder while chafa runs, error if chafa missing
- Add 6 tests for Image mode, 957 total passing
@sourcery-ai

sourcery-ai Bot commented May 23, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds an Image view mode to the internal viewer that renders image/* files as ANSI art using chafa, wires it into the main event loop and renderer, and documents the feature and dependency.

Sequence diagram for image preview generation in viewer

sequenceDiagram
    participant App as run_app
    participant VS as ViewerState
    participant Chafa
    participant AnsiToTui

    App->>VS: prepare_image_preview(area_width, area_height)
    alt cached_image_size matches
        VS-->>App: Ok(())
    else needs_new_preview
        VS->>Chafa: Command::new(chafa).arg(--size WxH).arg(file_path).output()
        Chafa-->>VS: Output { status, stdout, stderr }
        alt status.success
            VS->>AnsiToTui: stdout.into_text()
            AnsiToTui-->>VS: Text
            VS->>VS: cached_image_text = Some(Text)
        else failure_or_parse_error
            VS->>VS: cached_image_text = Some(Text::raw(error_message))
        end
        VS->>VS: cached_image_size = Some((area_width, content_height))
        VS-->>App: Ok(())
    end

    App->>App: terminal.draw(render_ui)
Loading

File-Level Changes

Change Details Files
Introduce Image view mode in the viewer and wire it into open/toggle behavior.
  • Extend ViewerState with cached_image_size and cached_image_text for storing last-rendered image preview and its size.
  • Decide initial view_mode based on new is_image_mime helper so image/* files open directly in Image mode.
  • Update toggle_hex_mode to flip between Hex and Image for image files, preserving original mode semantics.
  • Make toggle_wrap and toggle_line_numbers early-return when in Image mode, keeping text-only options as no-ops for images.
  • Add ViewMode::Image to the global view mode enum used by the app state.
src/ui/viewer.rs
src/app/types.rs
Add chafa-based image preview generation and rendering path.
  • Implement prepare_image_preview to spawn chafa with a size matching the content area, parse its ANSI output, and cache the resulting Text along with the size to avoid recomputation.
  • Use ansi-to-tui to convert chafa’s ANSI output into a Text object; fall back to error messages when chafa fails or ANSI parsing errors occur.
  • Render Image mode via render_image_view_with_colors, drawing a framed block, either cached image text or a 'Generating preview…' placeholder, and an Image-specific status line.
  • Call prepare_image_preview from the main event loop before drawing when in Image mode so the cache stays in sync with terminal size changes.
src/ui/viewer.rs
src/render.rs
src/main.rs
Improve MIME handling and add tests for image-viewing behavior.
  • Introduce is_image_mime helper to centralize detection of image MIME types and guard image-mode decisions.
  • Create a minimal PNG file helper and tests to assert that opening image files sets Image mode and MIME detection correctly.
  • Add tests to verify hex/image toggle behavior, wrap/line-number no-ops in Image mode, correct mode restoration after toggling, and non-panicking image render path.
src/ui/viewer.rs
Document the image preview feature and wire in the ansi-to-tui dependency.
  • Add ansi-to-tui as a runtime dependency in Cargo.toml and lockfile.
  • Document ansi-to-tui in the README dependency table and describe the new Image preview feature and controls, including chafa installation instructions and how the pipeline works.
Cargo.toml
Cargo.lock
README.md
Refactor terminal state recovery into a helper.
  • Extract terminal state recovery logic from run_app into a dedicated recover_terminal_state function and call it at startup.
  • Preserve existing behavior of leaving and resuming TUI stdout and removing the terminal state file while improving readability.
src/main.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In toggle_line_numbers and toggle_wrap, the is_hex_mode check was effectively inverted compared to the previous behavior (now forcing a Hex view into Text on toggle instead of leaving Hex unchanged), which is likely unintended and will change existing viewer behavior.
  • prepare_image_preview runs chafa synchronously from the main event loop on each size change; consider moving this into the existing background job infrastructure (similar to ViewerLoader) so that large images or slow chafa calls don't block UI rendering.
  • If spawning chafa fails (e.g., not installed), prepare_image_preview returns an error and cached_image_text stays None, so the UI shows "Generating preview…" indefinitely; you may want to catch this error, set a user-visible error message in cached_image_text, and/or special-case NotFound to match the README message.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `toggle_line_numbers` and `toggle_wrap`, the `is_hex_mode` check was effectively inverted compared to the previous behavior (now forcing a Hex view into Text on toggle instead of leaving Hex unchanged), which is likely unintended and will change existing viewer behavior.
- `prepare_image_preview` runs `chafa` synchronously from the main event loop on each size change; consider moving this into the existing background job infrastructure (similar to `ViewerLoader`) so that large images or slow `chafa` calls don't block UI rendering.
- If spawning `chafa` fails (e.g., not installed), `prepare_image_preview` returns an error and `cached_image_text` stays `None`, so the UI shows "Generating preview…" indefinitely; you may want to catch this error, set a user-visible error message in `cached_image_text`, and/or special-case `NotFound` to match the README message.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@greptile-apps

greptile-apps Bot commented May 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds image preview to the internal viewer (F3) by auto-detecting image/* MIME types and rendering character art via an external chafa invocation, with the result cached as parsed ansi-to-tui Text and a hex-mode toggle to switch between image and hex views.

  • ViewerState::prepare_image_preview spawns chafa synchronously on the main event-loop thread; errors from a missing or failing chafa are silently discarded at the call site, leaving cached_image_size unset and causing a failed process spawn on every subsequent dirty frame while the UI shows "Generating preview…" indefinitely.
  • toggle_line_numbers / toggle_wrap gain Image-mode guards and fix a pre-existing !is_hex_mode() negation bug; render_image_view_with_colors and the render_ui dispatch are wired cleanly.

Confidence Score: 3/5

Not ready to merge as-is: the chafa call blocks the event loop on every image open and resize, and a missing chafa binary causes a silent per-frame retry loop with no user-visible error.

The blocking subprocess on the main thread degrades the TUI on every image open, directly violating the project's event-loop contract. When chafa is absent, the silent error discard produces an infinite retry loop each dirty frame and contradicts the documented error message. Both issues affect the changed code path on every user interaction with the feature.

src/ui/viewer.rs (prepare_image_preview error handling and size calculation) and src/main.rs (blocking call placement in event loop)

Important Files Changed

Filename Overview
src/ui/viewer.rs Core of the feature: adds Image ViewMode, prepare_image_preview (blocking chafa subprocess), render_image_view_with_colors, and toggle guards. Two logic bugs: missing-chafa errors are silently dropped causing infinite retry, and off-by-one in chafa canvas height vs render area.
src/main.rs Adds prepare_image_preview call directly in the dirty-frame block; spawns a blocking subprocess on the event loop thread, violating the project's async/job_runner convention. Also extracts recover_terminal_state into its own function (safe).
src/render.rs Switches viewer dispatch from an if/else to a match on ViewMode, adding the Image branch. Clean, correct change.
src/app/types.rs Adds Image variant to ViewMode enum. Minimal, correct change.
Cargo.toml Adds ansi-to-tui 8.0.1 dependency for parsing chafa ANSI output.
README.md Documents Image mode, chafa installation, controls, and how-it-works. The "Failed to execute chafa" error message described here does not match the actual implementation which silently discards the error.

Sequence Diagram

sequenceDiagram
    participant EL as Event Loop (main.rs)
    participant VS as ViewerState
    participant CH as chafa (subprocess)
    participant ATT as ansi-to-tui
    participant UI as render_image_view

    EL->>VS: "dirty=true → prepare_image_preview(w, h)"
    VS->>VS: check cached_image_size (cache miss)
    VS->>CH: Command::new("chafa").output() [BLOCKING]
    CH-->>VS: stdout (ANSI bytes) / io::Error if missing
    Note over VS,CH: Error path: io::Error propagated via ?<br/>call site uses let _ = ..., error silently dropped<br/>cached_image_size stays None → retries every dirty frame
    VS->>ATT: stdout.into_text()
    ATT-->>VS: "Text<'static>"
    VS->>VS: "cached_image_size = Some((w, h-2))<br/>cached_image_text = Some(text)"
    EL->>UI: terminal.draw → render_image_view_with_colors
    UI->>UI: "content_area.height = h-3 (renders h-3 lines)<br/>chafa produced h-2 lines → last line clipped"
Loading

Comments Outside Diff (1)

  1. src/ui/viewer.rs, line 783-811 (link)

    P1 Missing chafa silently loops and never shows an error

    When chafa is not installed, std::process::Command::output() returns io::Error { kind: NotFound }, which is propagated via ? out of prepare_image_preview. The call site in main.rs discards this error with let _ = .... Because the cache fields (cached_image_size / cached_image_text) are only written after a successful output(), they remain None permanently. On every subsequent dirty frame the same NotFound error is thrown and swallowed — a busy retry loop that spawns a failing process every render tick. The UI remains stuck on "Generating preview…" indefinitely rather than showing the "Failed to execute chafa (is it installed?)" message documented in the README. The fix requires catching the io::Error in prepare_image_preview (or at the call site) and storing an error Text in cached_image_text while also setting cached_image_size to prevent further retries.

Reviews (1): Last reviewed commit: "feat(viewer): add image preview via chaf..." | Re-trigger Greptile

Comment thread src/main.rs
Comment on lines +210 to +215
if let Some(vs) = viewer_state.as_mut()
&& vs.view_mode == ViewMode::Image
&& let Ok(size) = terminal.size()
{
let _ = vs.prepare_image_preview(size.width, size.height);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Blocking subprocess call freezes the TUI event loop

prepare_image_preview calls std::process::Command::output(), which blocks the calling thread until the chafa process exits. Because this is called directly inside the if dirty block of the main event loop, every first load and every terminal resize stalls keyboard/mouse event processing for the full duration of chafa execution. On large images or slow terminals this is visibly noticeable, and on a busy system it can take seconds. The project's AGENTS.md explicitly states: "offload heavy work via rayon or app::job_runner" — the same pattern used for directory reads and file operations. The chafa invocation should be dispatched as a background job and its result sent back through a channel, leaving the event loop free to process input and redraw.

Comment thread src/ui/viewer.rs
Comment on lines +774 to +780

pub fn prepare_image_preview(&mut self, area_width: u16, area_height: u16) -> io::Result<()> {
let content_height = area_height.saturating_sub(2);
if area_width == 0 || content_height == 0 {
return Ok(());
}
if self.cached_image_size == Some((area_width, content_height)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Off-by-one between chafa invocation size and actual render area

prepare_image_preview computes content_height = area_height.saturating_sub(2), so it tells chafa to produce output H-2 lines tall. However, render_image_view_with_colors subtracts 3 from the same height: Margin { vertical: 1 } removes 2 lines (top+bottom borders), and then inner_area.height.saturating_sub(1) removes a further line for the status bar. The actual content area rendered is H-3, one line shorter than the chafa canvas. The last row of chafa output is clipped or pushed into the status bar area.

Comment thread src/ui/viewer.rs Outdated
Comment on lines +834 to +836
fn is_image_mime(mime: &Option<String>) -> bool {
mime.as_ref().is_some_and(|m| m.starts_with("image/"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 is_image_mime takes &Option<String> where Option<&str> would be more idiomatic

Passing &Option<T> and calling .as_ref() internally is less ergonomic than accepting Option<&str> directly, which is the canonical Rust pattern for borrowed optional strings.

Suggested change
fn is_image_mime(mime: &Option<String>) -> bool {
mime.as_ref().is_some_and(|m| m.starts_with("image/"))
}
fn is_image_mime(mime: Option<&str>) -> bool {
mime.is_some_and(|m| m.starts_with("image/"))
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an image preview feature to the file viewer by utilizing chafa to render images as ANSI character art. Key changes include adding the ansi-to-tui dependency, updating ViewerState to support image detection and caching, and implementing the corresponding UI rendering logic. Review feedback identifies a significant responsiveness issue caused by executing the chafa process synchronously within the main event loop, which can freeze the UI. Furthermore, improvements were suggested to correct the image height calculation to avoid UI overlap and to provide better error messaging if the chafa executable is not found.

Comment thread src/main.rs
Comment on lines +210 to +215
if let Some(vs) = viewer_state.as_mut()
&& vs.view_mode == ViewMode::Image
&& let Ok(size) = terminal.size()
{
let _ = vs.prepare_image_preview(size.width, size.height);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Spawning a process and waiting for its output (Command::output()) inside the main event loop is a blocking operation. This will cause the UI to freeze while chafa is processing the image, which can be significant for large files or slow systems. Consider moving this to a background thread or integrating it into the existing ViewerLoader mechanism to maintain responsiveness.

Comment thread src/ui/viewer.rs Outdated
Comment on lines +775 to +807
pub fn prepare_image_preview(&mut self, area_width: u16, area_height: u16) -> io::Result<()> {
let content_height = area_height.saturating_sub(2);
if area_width == 0 || content_height == 0 {
return Ok(());
}
if self.cached_image_size == Some((area_width, content_height)) {
return Ok(());
}

let size_str = format!("{}x{}", area_width, content_height);
let output = std::process::Command::new("chafa")
.arg("-f")
.arg("symbols")
.arg("--size")
.arg(&size_str)
.arg("--")
.arg(&self.file_path)
.output()?;

let parsed_text = if output.status.success() {
match output.stdout.into_text() {
Ok(text) => text,
Err(e) => Text::raw(format!("Failed to parse ANSI: {}", e)),
}
} else {
let err_msg = String::from_utf8_lossy(&output.stderr);
Text::raw(format!("Chafa error: {}", err_msg))
};

self.cached_image_size = Some((area_width, content_height));
self.cached_image_text = Some(parsed_text);
Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The image preview logic has two issues:

  1. Height calculation mismatch: The height calculation for chafa subtracts 2, but render_image_view_with_colors uses 3 lines for overhead (2 for borders, 1 for status bar). This will cause the image to be one line too tall, potentially overlapping the status bar or being truncated.
  2. Error handling: If the chafa executable is missing, Command::output() returns an io::Error. The current use of ? propagates this error, but it is ignored in the main loop, leaving the user with a "Generating preview..." message indefinitely. It's better to catch the error here and return a Text object containing a helpful error message.
    pub fn prepare_image_preview(&mut self, area_width: u16, area_height: u16) -> io::Result<()> {
        let content_height = area_height.saturating_sub(3);
        if area_width == 0 || content_height == 0 {
            return Ok(());
        }
        if self.cached_image_size == Some((area_width, content_height)) {
            return Ok(());
        }

        let size_str = format!("{}x{}", area_width, content_height);
        let output = std::process::Command::new("chafa")
            .arg("-f")
            .arg("symbols")
            .arg("--size")
            .arg(&size_str)
            .arg("--")
            .arg(&self.file_path)
            .output();

        let parsed_text = match output {
            Ok(out) if out.status.success() => match out.stdout.into_text() {
                Ok(text) => text,
                Err(e) => Text::raw(format!("Failed to parse ANSI: {e}")),
            },
            Ok(out) => {
                let err_msg = String::from_utf8_lossy(&out.stderr);
                Text::raw(format!("Chafa error: {err_msg}"))
            }
            Err(e) => Text::raw(format!("Failed to execute chafa (is it installed?): {e}")),
        };

        self.cached_image_size = Some((area_width, content_height));
        self.cached_image_text = Some(parsed_text);
        Ok(())
    }

…semantics

- Fix content_height: saturating_sub(2) → saturating_sub(3) to match
  actual render area (2 border + 1 status bar = 3)
- Catch chafa spawn failure in prepare_image_preview, store error
  message in cache so user sees it instead of indefinite placeholder
- Restore old !is_hex_mode() condition in toggle_wrap/toggle_line_numbers
  so Hex mode is not forcibly switched to Text on toggle
- Change is_image_mime signature: &Option<String> → Option<&str>
@leszek3737
leszek3737 merged commit d42f25c into main May 23, 2026
5 checks passed
@leszek3737
leszek3737 deleted the image branch May 24, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant