> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/sxyazi/yazi/llms.txt
> Use this file to discover all available pages before exploring further.

# Previewers

> Create custom file preview plugins for Yazi

Previewer plugins generate visual previews of files in Yazi's preview pane. They can display text, images, videos, archives, and any custom format.

## What Are Previewers?

Previewers show file contents in the right pane of Yazi. Built-in previewers handle:

* Code files with syntax highlighting
* Images (PNG, JPEG, WebP, etc.)
* Videos (thumbnails/frames)
* Archives (file listings)
* PDFs (rendered pages)
* Folders (directory listings)

## Previewer Structure

Previewers implement these methods:

```lua theme={null}
local M = {}

-- Required: Generate preview
function M:peek(job)
  -- Display preview
end

-- Required: Handle scrolling
function M:seek(job)
  -- Respond to scroll events
end

-- Optional: Preload/cache content
function M:preload(job)
  -- Prepare content in background
  return true  -- or false to skip preview
end

return M
```

### The Job Object

```lua theme={null}
function M:peek(job)
  job.file      -- File object (url, name, cha, etc.)
  job.area      -- Preview area (x, y, w, h)
  job.skip      -- Scroll offset (lines or pages)
  job.args      -- Additional arguments
end
```

## Example: Image Previewer

Here's Yazi's built-in image previewer:

```lua theme={null}
local M = {}

function M:peek(job)
  local start, url = os.clock(), ya.file_cache(job)
  if not url or not fs.cha(url) then
    url = job.file.url
  end
  
  -- Wait for image delay
  ya.sleep(math.max(0, rt.preview.image_delay / 1000 + start - os.clock()))
  
  -- Show image in preview area
  local _, err = ya.image_show(url, job.area)
  ya.preview_widget(job, err)
end

function M:seek() end

function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache or fs.cha(cache) then
    return true
  end
  
  -- Precache image for faster display
  return ya.image_precache(job.file.url, cache)
end

return M
```

Configure in `yazi.toml`:

```toml theme={null}
[[plugin.previewers]]
mime = "image/*"
run = "image"
```

## Example: Code Previewer

Display code files with syntax highlighting:

```lua theme={null}
local M = {}

function M:peek(job)
  local err, bound = ya.preview_code(job)
  if bound then
    -- Scrolled past end, emit new peek with correct bound
    ya.emit("peek", {
      bound,
      only_if = job.file.url,
      upper_bound = true
    })
  elseif err and not err:find("cancelled", 1, true) then
    -- Show error message
    require("empty").msg(job, err)
  end
end

function M:seek(job)
  local h = cx.active.current.hovered
  if not h or h.url ~= job.file.url then
    return
  end
  
  -- Calculate scroll step
  local step = math.floor(job.units * job.area.h / 10)
  step = step == 0 and ya.clamp(-1, job.units, 1) or step
  
  -- Emit new peek with updated offset
  ya.emit("peek", {
    math.max(0, cx.active.preview.skip + step),
    only_if = job.file.url,
  })
end

return M
```

## Example: Video Previewer

Extract video frames as previews:

```lua theme={null}
local M = {}

function M:peek(job)
  local start, cache = os.clock(), ya.file_cache(job)
  if not cache then
    return
  end
  
  local ok, err = self:preload(job)
  if not ok or err then
    return ya.preview_widget(job, err)
  end
  
  ya.sleep(math.max(0, rt.preview.image_delay / 1000 + start - os.clock()))
  
  local _, err = ya.image_show(cache, job.area)
  ya.preview_widget(job, err)
end

function M:seek(job)
  local h = cx.active.current.hovered
  if h and h.url == job.file.url then
    ya.emit("peek", {
      math.max(0, cx.active.preview.skip + job.units),
      only_if = job.file.url,
    })
  end
end

function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache then
    return true
  end
  
  local cha = fs.cha(cache)
  if cha and cha.len > 0 then
    return true
  end
  
  -- Get video duration
  local meta, err = self.list_meta(
    job.file.path,
    "format=duration:stream_disposition=attached_pic"
  )
  if not meta then
    return true, err
  elseif not meta.format.duration then
    return true, Err("Failed to get video duration")
  end
  
  -- Calculate frame position
  local percent = 5 + job.skip
  if percent > 95 then
    ya.emit("peek", { 90, only_if = job.file.url, upper_bound = true })
    return false
  end
  
  -- Extract frame with ffmpeg
  local output, err = Command("ffmpeg")
    :arg({
      "-v", "warning",
      "-hwaccel", "auto",
      "-ss", math.floor(meta.format.duration * percent / 100),
      "-skip_frame", "nokey",
      "-i", tostring(job.file.path),
      "-vframes", 1,
      "-q:v", 31 - math.floor(rt.preview.image_quality * 0.3),
      "-vf", string.format(
        "scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease",
        rt.preview.max_width,
        rt.preview.max_height
      ),
      "-f", "image2",
      "-y", tostring(cache),
    })
    :stderr(Command.PIPED)
    :output()
  
  if not output then
    return true, Err("Failed to start ffmpeg: %s", err)
  elseif output.status.success then
    return true
  else
    return false, Err("ffmpeg error: %s", output.stderr)
  end
end

function M.list_meta(path, entries)
  local output, err = Command("ffprobe")
    :arg({
      "-v", "quiet",
      "-show_entries", entries,
      "-of", "json=c=1",
      tostring(path)
    })
    :output()
  
  if not output then
    return nil, Err("Failed to start ffprobe: %s", err)
  end
  
  local t = ya.json_decode(output.stdout)
  if not t then
    return nil, Err("Failed to decode ffprobe output")
  end
  
  t.format = t.format or {}
  t.streams = t.streams or {}
  return t
end

return M
```

## Example: PDF Previewer

Render PDF pages as images:

```lua theme={null}
local M = {}

function M:peek(job)
  local start, cache = os.clock(), ya.file_cache(job)
  if not cache then
    return
  end
  
  local ok, err, bound = self:preload(job)
  if bound and bound > 0 then
    -- User scrolled past last page
    return ya.emit("peek", {
      bound - 1,
      only_if = job.file.url,
      upper_bound = true
    })
  elseif not ok or err then
    return ya.preview_widget(job, err)
  end
  
  ya.sleep(math.max(0, rt.preview.image_delay / 1000 + start - os.clock()))
  
  local _, err = ya.image_show(cache, job.area)
  ya.preview_widget(job, err)
end

function M:seek(job)
  local h = cx.active.current.hovered
  if h and h.url == job.file.url then
    local step = ya.clamp(-1, job.units, 1)
    ya.emit("peek", {
      math.max(0, cx.active.preview.skip + step),
      only_if = job.file.url
    })
  end
end

function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache or fs.cha(cache) then
    return true
  end
  
  -- Convert PDF page to JPEG with pdftoppm
  local output, err = Command("pdftoppm")
    :arg({
      "-f", job.skip + 1,     -- First page
      "-l", job.skip + 1,     -- Last page
      "-singlefile",
      "-jpeg",
      "-jpegopt", "quality=" .. rt.preview.image_quality,
      tostring(job.file.path),
      tostring(cache),
    })
    :output()
  
  if not output then
    return true, Err("Failed to start pdftoppm: %s", err)
  elseif not output.status.success then
    -- Extract page count from error
    local pages = job.skip > 0 and tonumber(
      output.stderr:match("the last page %((%d+)%)")
    )
    return true, Err("Failed to convert PDF: %s", output.stderr), pages
  end
  
  -- pdftoppm adds .jpg extension
  return ya.image_precache(Url(cache .. ".jpg"), cache)
end

return M
```

## Example: Archive Previewer

List contents of archive files:

```lua theme={null}
local M = {}

function M:peek(job)
  local limit = job.area.h
  local files, err = self.list_archive(
    { "-p", tostring(job.file.path) },
    job.skip,
    limit
  )
  
  if err then
    return ya.preview_widget(job, err)
  elseif job.skip > 0 and #files < job.skip + limit then
    return ya.emit("peek", {
      math.max(0, #files - limit),
      only_if = job.file.url,
      upper_bound = true
    })
  end
  
  -- Build file list display
  local left, right = {}, {}
  for i = job.skip + 1, #files do
    local f = files[i]
    local icon = File({
      url = Url(f.path),
      cha = Cha { mode = tonumber(f.is_dir and "40700" or "100644", 8) },
    }):icon()
    
    -- Right side: size
    if f.size > 0 then
      right[#right + 1] = " " .. ya.readable_size(f.size) .. " "
    else
      right[#right + 1] = " "
    end
    
    -- Left side: icon and name
    if icon then
      left[#left + 1] = ui.Span(" " .. icon.text .. " "):style(icon.style)
    else
      left[#left + 1] = " "
    end
    
    left[#left] = ui.Line {
      string.rep(" │", f.depth),
      left[#left],
      ui.truncate(f.path.name or tostring(f.path), {
        rtl = true,
        max = math.max(0, job.area.w - (f.depth * 2) 
          - ui.width(left[#left]) - ui.width(right[#right])),
      }),
    }
  end
  
  ya.preview_widget(job, {
    ui.Text(left):area(job.area),
    ui.Text(right):area(job.area):align(ui.Align.RIGHT),
  })
end

function M:seek(job)
  require("code"):seek(job)
end

function M.list_archive(args, skip, limit)
  local child = M.spawn_7z({
    "l", "-ba", "-slt", "-sccUTF-8",
    table.unpack(args)
  })
  
  if not child then
    return {}, Err("Failed to start 7-zip")
  end
  
  local files = {}
  -- Parse 7-zip output...
  -- (Implementation details omitted)
  
  child:start_kill()
  return files, nil
end

function M.spawn_7z(args)
  local child = Command("7zz"):arg(args):stdout(Command.PIPED):spawn()
  if not child then
    child = Command("7z"):arg(args):stdout(Command.PIPED):spawn()
  end
  return child
end

return M
```

## Preview Widgets

### Display Text

```lua theme={null}
local text = ui.Text("content\nline 2\nline 3")
  :area(job.area)
  :wrap(ui.Wrap.YES)
  :align(ui.Align.CENTER)

ya.preview_widget(job, text)
```

### Display Images

```lua theme={null}
local _, err = ya.image_show(url, job.area)
ya.preview_widget(job, err)
```

### Display List

```lua theme={null}
local lines = {
  ui.Line("item 1"),
  ui.Line("item 2"),
  ui.Line("item 3"),
}

ya.preview_widget(job, {
  ui.List(lines):area(job.area),
})
```

### Display Error

```lua theme={null}
ya.preview_widget(job, Err("Failed to load: %s", error_msg))
```

## Caching

Use `ya.file_cache()` to get cache paths:

```lua theme={null}
function M:preload(job)
  local cache = ya.file_cache(job)
  if not cache then
    return true  -- No cache available
  end
  
  -- Check if already cached
  if fs.cha(cache) then
    return true  -- Cache exists
  end
  
  -- Generate cache
  Command("convert")
    :arg({ tostring(job.file.path), tostring(cache) })
    :status()
  
  return true
end
```

## Scrolling

Handle scroll with `seek()`:

```lua theme={null}
function M:seek(job)
  local h = cx.active.current.hovered
  if not h or h.url ~= job.file.url then
    return
  end
  
  -- job.units: positive = down, negative = up
  local step = job.units
  
  -- Emit new peek with updated offset
  ya.emit("peek", {
    math.max(0, cx.active.preview.skip + step),
    only_if = job.file.url,
  })
end
```

## Upper Bounds

Tell Yazi the maximum scroll position:

```lua theme={null}
if job.skip > max_lines then
  ya.emit("peek", {
    max_lines,  -- Correct upper bound
    only_if = job.file.url,
    upper_bound = true
  })
  return
end
```

## Best Practices

<AccordionGroup>
  <Accordion title="Always implement seek()">
    Even if scrolling isn't supported:

    ```lua theme={null}
    function M:seek() end
    ```
  </Accordion>

  <Accordion title="Use preload for expensive operations">
    Generate previews in background:

    ```lua theme={null}
    function M:preload(job)
      -- Heavy computation here
      return true
    end
    ```
  </Accordion>

  <Accordion title="Handle missing tools gracefully">
    Check if external tools exist:

    ```lua theme={null}
    local output, err = Command("tool"):output()
    if not output then
      return ya.preview_widget(job, 
        Err("tool not found: %s", err))
    end
    ```
  </Accordion>

  <Accordion title="Respect size limits">
    Check configuration:

    ```lua theme={null}
    if width > rt.preview.max_width or 
       height > rt.preview.max_height then
      -- Resize or reject
    end
    ```
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Fetchers" icon="download" href="plugins/fetchers">
    Create metadata fetchers
  </Card>

  <Card title="UI Plugins" icon="palette" href="plugins/ui-plugins">
    Customize the interface
  </Card>
</CardGroup>
