diff --git a/internal/ui/image_overlay.go b/internal/ui/image_overlay.go new file mode 100644 index 0000000..d25699b --- /dev/null +++ b/internal/ui/image_overlay.go @@ -0,0 +1,258 @@ +package ui + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "slices" + "strings" +) + +type overlayRect struct { + x int + y int + width int + height int +} + +type imageOverlayManager struct { + cmd *exec.Cmd + stdin io.WriteCloser + running bool + identifier string + visible bool + backend string + backends []string + lastPath string + lastRect overlayRect + kittyTried bool + kittyOK bool +} + +func newImageOverlayManager() *imageOverlayManager { + return &imageOverlayManager{identifier: "vcom-preview"} +} + +func (m *imageOverlayManager) isKittyTerminal() bool { + term := strings.ToLower(os.Getenv("TERM")) + termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM")) + return os.Getenv("KITTY_WINDOW_ID") != "" || strings.Contains(term, "kitty") || strings.Contains(termProgram, "kitty") +} + +func (m *imageOverlayManager) canUseKitty() bool { + if !m.isKittyTerminal() { + return false + } + if m.kittyTried { + return m.kittyOK + } + m.kittyTried = true + _, err := exec.LookPath("kitty") + m.kittyOK = err == nil + return m.kittyOK +} + +func (m *imageOverlayManager) showWithKitty(path string, rect overlayRect) error { + args := []string{ + "+kitten", "icat", + "--stdin=no", + "--transfer-mode=stream", + "--image-id=31337", + "--place", fmt.Sprintf("%dx%d@%dx%d", rect.width, rect.height, rect.x, rect.y), + "--scale-up", + "--no-trailing-newline", + path, + } + cmd := exec.Command("kitty", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = io.Discard + return cmd.Run() +} + +func (m *imageOverlayManager) clearKitty() { + cmd := exec.Command("kitty", "+kitten", "icat", "--stdin=no", "--clear") + cmd.Stdout = os.Stdout + cmd.Stderr = io.Discard + _ = cmd.Run() +} + +func (m *imageOverlayManager) backendOutput() string { + term := strings.ToLower(os.Getenv("TERM")) + order := make([]string, 0, 5) + switch { + case strings.Contains(term, "kitty"): + order = append(order, "kitty") + case os.Getenv("WAYLAND_DISPLAY") != "": + order = append(order, "wayland") + case os.Getenv("DISPLAY") != "": + order = append(order, "x11") + } + order = append(order, "wayland", "x11", "sixel", "kitty", "chafa") + + unique := make([]string, 0, len(order)) + for _, backend := range order { + if !slices.Contains(unique, backend) { + unique = append(unique, backend) + } + } + return strings.Join(unique, ",") +} + +func (m *imageOverlayManager) backendList() []string { + if len(m.backends) != 0 { + return m.backends + } + m.backends = strings.Split(m.backendOutput(), ",") + return m.backends +} + +func (m *imageOverlayManager) startBackend(backend string) error { + cmd := exec.Command("ueberzugpp", "layer", "-o", backend) + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Start(); err != nil { + _ = stdin.Close() + return err + } + m.cmd = cmd + m.stdin = stdin + m.running = true + m.backend = backend + return nil +} + +func (m *imageOverlayManager) ensureStarted() error { + if m.running { + return nil + } + if _, err := exec.LookPath("ueberzugpp"); err != nil { + return err + } + + var lastErr error + for _, backend := range m.backendList() { + if err := m.startBackend(backend); err != nil { + lastErr = err + continue + } + // Probe command channel right away; some backends terminate instantly. + if err := m.send(map[string]any{ + "action": "remove", + "identifier": m.identifier, + }); err != nil { + lastErr = err + m.stop() + continue + } + return nil + } + + if lastErr != nil { + return lastErr + } + return fmt.Errorf("could not start ueberzugpp overlay") +} + +func (m *imageOverlayManager) send(payload map[string]any) error { + if !m.running || m.stdin == nil { + return fmt.Errorf("overlay not running") + } + data, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = io.WriteString(m.stdin, string(data)+"\n") + return err +} + +func (m *imageOverlayManager) show(path string, rect overlayRect) error { + if rect.width <= 1 || rect.height <= 1 { + return nil + } + if m.visible && m.lastPath == path && m.lastRect == rect { + return nil + } + + if m.canUseKitty() { + if m.backend == "ueberzugpp" { + m.hide() + } + if err := m.showWithKitty(path, rect); err == nil { + m.backend = "kitty" + m.visible = true + m.lastPath = path + m.lastRect = rect + return nil + } + } + + for i := 0; i < len(m.backendList()); i++ { + if err := m.ensureStarted(); err != nil { + return err + } + payload := map[string]any{ + "action": "add", + "identifier": m.identifier, + "path": path, + "x": rect.x, + "y": rect.y, + "max_width": rect.width, + "max_height": rect.height, + "scaler": "fit_contain", + } + if err := m.send(payload); err == nil { + m.backend = "ueberzugpp" + m.visible = true + m.lastPath = path + m.lastRect = rect + return nil + } + + m.stop() + if len(m.backends) > 0 { + m.backends = append(m.backends[1:], m.backends[0]) + } + } + return fmt.Errorf("could not render image overlay") +} + +func (m *imageOverlayManager) hide() { + if !m.visible { + return + } + switch m.backend { + case "kitty": + m.clearKitty() + case "ueberzugpp": + if m.running { + _ = m.send(map[string]any{ + "action": "remove", + "identifier": m.identifier, + }) + } + } + m.visible = false + m.lastPath = "" + m.lastRect = overlayRect{} +} + +func (m *imageOverlayManager) stop() { + m.hide() + if m.stdin != nil { + _ = m.stdin.Close() + m.stdin = nil + } + if m.cmd != nil && m.cmd.Process != nil { + _ = m.cmd.Process.Kill() + _, _ = m.cmd.Process.Wait() + } + m.cmd = nil + m.running = false + m.backend = "" +} diff --git a/internal/ui/model.go b/internal/ui/model.go index f985ccb..efc6aab 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -142,6 +142,7 @@ type Model struct { palette theme.Palette keys KeyMap nerdIcons bool + overlay *imageOverlayManager width int height int @@ -194,6 +195,7 @@ func NewModel(cfg config.Config, configPath string) (Model, error) { configPath: configPath, palette: palette, keys: DefaultKeyMap(), + overlay: newImageOverlayManager(), left: BrowserPane{ID: PaneLeft, Path: leftPath}, right: BrowserPane{ID: PaneRight, Path: rightPath}, active: PaneLeft, @@ -438,6 +440,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Quit): m.cleanupArchiveMounts() + m.cleanupImageOverlay() return m, tea.Quit case key.Matches(msg, m.keys.Help): m.openHelpModal() @@ -546,7 +549,13 @@ func (m Model) View() string { Render("") var panels string - if m.selectMode && m.infoMode { + if m.viewMode && m.previewData.Kind == vfs.PreviewKindImage { + panels = lipgloss.NewStyle(). + Width(m.width). + Height(bodyHeight). + Background(m.palette.Background). + Render("") + } else if m.selectMode && m.infoMode { panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) } else if m.infoMode { if m.active == PaneLeft { @@ -586,12 +595,17 @@ func (m Model) View() string { Foreground(m.palette.Text). Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) if m.modal.kind != modalNone { + if m.overlay != nil { + m.overlay.hide() + } modalWidth := min(72, m.width-8) if m.modal.kind == modalHelp { modalWidth = min(96, m.width-8) } view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width) + return view } + m.syncImageOverlay(leftWidth, previewWidth, bodyHeight) return view } @@ -869,12 +883,6 @@ func (m Model) loadPreviewCmd() tea.Cmd { HumanReadableSize: m.cfg.Browser.HumanReadableSize, ThemeName: m.cfg.UI.Theme, UseNerdIcons: m.nerdIcons, - ImagePreviewWidth: max(m.previewModel.Width-2, 20), - ImagePreviewHeight: max(m.previewModel.Height-6, 8), - } - if m.viewMode { - options.ImagePreviewWidth = max(m.width-8, 20) - options.ImagePreviewHeight = max(m.bodyHeight()-8, 8) } return func() tea.Msg { @@ -976,12 +984,6 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) { m.status = "Select a file to view" return m, nil } - if selected.Category() == "image" { - if _, err := exec.LookPath("chafa"); err != nil { - m.status = "Install `chafa` to view images in terminal" - return m, nil - } - } if m.viewMode { return m.exitViewMode() } @@ -1022,6 +1024,7 @@ func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) { return m, nil } + 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} @@ -1041,6 +1044,7 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) { return m, nil } + 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: opEdit, sourcePath: selected.Path, err: err} @@ -2718,6 +2722,60 @@ func isArchiveEntry(entry vfs.Entry) bool { return !entry.IsDir && !entry.IsParent && entry.Category() == "archive" } +func (m Model) syncImageOverlay(leftWidth int, previewWidth int, bodyHeight int) { + if m.overlay == nil { + return + } + if m.modal.kind != modalNone { + m.overlay.hide() + return + } + if m.previewData.Kind != vfs.PreviewKindImage { + m.overlay.hide() + return + } + imagePath := strings.TrimSpace(m.previewData.Metadata.Path) + if imagePath == "" { + m.overlay.hide() + return + } + + rect := overlayRect{} + if m.viewMode { + rect = overlayRect{ + x: 1, + y: 1, + width: max(m.width-2, 1), + height: max(bodyHeight-2, 1), + } + } else if m.infoMode { + startX := 0 + if m.active == PaneLeft { + startX = leftWidth + m.cfg.UI.PaneGap + } + rect = overlayRect{ + x: startX + 2, + y: 9, + width: max(previewWidth-4, 1), + height: max(bodyHeight-11, 1), + } + } else { + m.overlay.hide() + return + } + + if err := m.overlay.show(imagePath, rect); err != nil { + m.overlay.hide() + } +} + +func (m *Model) cleanupImageOverlay() { + if m.overlay == nil { + return + } + m.overlay.stop() +} + func (m *Model) hoverIndexFor(pane PaneID) int { if m.hover.ok && m.hover.pane == pane { return m.hover.index