Refine image preview integration

This commit is contained in:
vrubelroman 2026-04-24 22:09:54 +03:00
parent 9dcef02e0d
commit 912de45e19
5 changed files with 43 additions and 48 deletions

View file

@ -147,6 +147,5 @@ Release artifacts:
## Notes
- File creation time depends on filesystem/OS support; unavailable values are shown as `n/a`.
- Image preview in info pane (`F9`) and image full-screen view (`F3`) use `chafa`.
Architecture notes: [docs/architecture.md](/home/vrubel/projects/vcom/docs/architecture.md)

View file

@ -35,11 +35,6 @@
package = pkgs.symlinkJoin {
name = "vcom";
paths = [ packageBase ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram "$out/bin/vcom" \
--prefix PATH : "${lib.makeBinPath [ pkgs.chafa ]}"
'';
};
in {
packages.default = package;

View file

@ -9,7 +9,6 @@ import (
_ "image/png"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
@ -123,9 +122,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview {
preview.Metadata.ImageFormat = format
preview.Metadata.ImageSize = dimensions
inline := renderImageInlinePreview(entry.Path, options.ImagePreviewWidth, options.ImagePreviewHeight)
if inline == "" {
preview.Body = "Image preview unavailable.\n\nInstall `chafa` for inline preview in info pane."
} else {
if inline != "" {
preview.Body = inline
}
preview.PlainBody = preview.Body
@ -381,36 +378,7 @@ func previewIcon(entry Entry, useNerdIcons bool) string {
}
func renderImageInlinePreview(path string, width int, height int) string {
if width < 20 {
width = 20
}
if height < 8 {
height = 8
}
if _, err := exec.LookPath("chafa"); err != nil {
return ""
}
cmd := exec.Command(
"chafa",
"--format=symbols",
"--symbols=vhalf",
"--animate=off",
"--fg-only",
"--size", fmt.Sprintf("%dx%d", width, height),
path,
)
out, err := cmd.Output()
if err != nil {
return ""
}
view := strings.TrimSpace(string(out))
if view == "" {
return ""
}
return view
return ""
}
func detectImage(data []byte) (string, string, bool) {

View file

@ -89,7 +89,7 @@ func (m *imageOverlayManager) backendOutput() string {
case os.Getenv("DISPLAY") != "":
order = append(order, "x11")
}
order = append(order, "wayland", "x11", "sixel", "kitty", "chafa")
order = append(order, "wayland", "x11", "sixel", "kitty")
unique := make([]string, 0, len(order))
for _, backend := range order {

View file

@ -3,6 +3,7 @@ package ui
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@ -111,6 +112,10 @@ type copyDoneMsg struct {
}
type dismissNoticeMsg struct{}
type externalOpenMsg struct {
path string
err error
}
type copyJobState struct {
id int
@ -412,6 +417,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case externalOpenMsg:
if msg.err != nil {
m.status = fmt.Sprintf("Open failed: %v", msg.err)
return m, nil
}
m.status = fmt.Sprintf("Opened %s", filepath.Base(msg.path))
return m, nil
case tea.KeyMsg:
if m.modal.kind != modalNone {
return m.handleModalKey(msg)
@ -1026,9 +1039,7 @@ func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) {
m.cleanupImageOverlay()
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
return m, tea.ExecProcess(command, func(err error) tea.Msg {
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
})
return m, startExternalOpenCmd(command, selected.Path)
}
func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
@ -2598,6 +2609,18 @@ func externalCommandFromEnv(envVars []string, fallbacks []string, path string) (
return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil
}
func startExternalOpenCmd(command *exec.Cmd, path string) tea.Cmd {
return func() tea.Msg {
command.Stdin = nil
command.Stdout = io.Discard
command.Stderr = io.Discard
if err := command.Start(); err != nil {
return externalOpenMsg{path: path, err: err}
}
return externalOpenMsg{path: path}
}
}
func enableMouseCmd() tea.Cmd {
return func() tea.Msg {
return tea.EnableMouseCellMotion()
@ -2753,11 +2776,21 @@ func (m Model) syncImageOverlay(leftWidth int, previewWidth int, bodyHeight int)
if m.active == PaneLeft {
startX = leftWidth + m.cfg.UI.PaneGap
}
innerWidth := max(previewWidth-2, 1)
metaHeight := 0
if m.cfg.Preview.ShowMetadata {
metaHeight = lipgloss.Height(renderMetadata(m.previewData.Metadata, m.palette, innerWidth))
}
titleHeight := 1
topInset := 1
contentBorder := 1
safetyGap := 1
contentTop := topInset + titleHeight + metaHeight + contentBorder + safetyGap
rect = overlayRect{
x: startX + 2,
y: 9,
width: max(previewWidth-4, 1),
height: max(bodyHeight-11, 1),
x: startX + 3,
y: contentTop,
width: max(previewWidth-6, 1),
height: max(bodyHeight-contentTop-2, 1),
}
} else {
m.overlay.hide()