Improve image preview rendering with kitty overlay
This commit is contained in:
parent
87f43e0c6a
commit
9dcef02e0d
2 changed files with 329 additions and 13 deletions
258
internal/ui/image_overlay.go
Normal file
258
internal/ui/image_overlay.go
Normal file
|
|
@ -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 = ""
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,7 @@ type Model struct {
|
||||||
palette theme.Palette
|
palette theme.Palette
|
||||||
keys KeyMap
|
keys KeyMap
|
||||||
nerdIcons bool
|
nerdIcons bool
|
||||||
|
overlay *imageOverlayManager
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
@ -194,6 +195,7 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
keys: DefaultKeyMap(),
|
keys: DefaultKeyMap(),
|
||||||
|
overlay: newImageOverlayManager(),
|
||||||
left: BrowserPane{ID: PaneLeft, Path: leftPath},
|
left: BrowserPane{ID: PaneLeft, Path: leftPath},
|
||||||
right: BrowserPane{ID: PaneRight, Path: rightPath},
|
right: BrowserPane{ID: PaneRight, Path: rightPath},
|
||||||
active: PaneLeft,
|
active: PaneLeft,
|
||||||
|
|
@ -438,6 +440,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Quit):
|
case key.Matches(msg, m.keys.Quit):
|
||||||
m.cleanupArchiveMounts()
|
m.cleanupArchiveMounts()
|
||||||
|
m.cleanupImageOverlay()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case key.Matches(msg, m.keys.Help):
|
case key.Matches(msg, m.keys.Help):
|
||||||
m.openHelpModal()
|
m.openHelpModal()
|
||||||
|
|
@ -546,7 +549,13 @@ func (m Model) View() string {
|
||||||
Render("")
|
Render("")
|
||||||
|
|
||||||
var panels string
|
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)
|
panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
|
||||||
} else if m.infoMode {
|
} else if m.infoMode {
|
||||||
if m.active == PaneLeft {
|
if m.active == PaneLeft {
|
||||||
|
|
@ -586,12 +595,17 @@ func (m Model) View() string {
|
||||||
Foreground(m.palette.Text).
|
Foreground(m.palette.Text).
|
||||||
Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
|
Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
|
||||||
if m.modal.kind != modalNone {
|
if m.modal.kind != modalNone {
|
||||||
|
if m.overlay != nil {
|
||||||
|
m.overlay.hide()
|
||||||
|
}
|
||||||
modalWidth := min(72, m.width-8)
|
modalWidth := min(72, m.width-8)
|
||||||
if m.modal.kind == modalHelp {
|
if m.modal.kind == modalHelp {
|
||||||
modalWidth = min(96, m.width-8)
|
modalWidth = min(96, m.width-8)
|
||||||
}
|
}
|
||||||
view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width)
|
view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width)
|
||||||
|
return view
|
||||||
}
|
}
|
||||||
|
m.syncImageOverlay(leftWidth, previewWidth, bodyHeight)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -869,12 +883,6 @@ func (m Model) loadPreviewCmd() tea.Cmd {
|
||||||
HumanReadableSize: m.cfg.Browser.HumanReadableSize,
|
HumanReadableSize: m.cfg.Browser.HumanReadableSize,
|
||||||
ThemeName: m.cfg.UI.Theme,
|
ThemeName: m.cfg.UI.Theme,
|
||||||
UseNerdIcons: m.nerdIcons,
|
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 {
|
return func() tea.Msg {
|
||||||
|
|
@ -976,12 +984,6 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) {
|
||||||
m.status = "Select a file to view"
|
m.status = "Select a file to view"
|
||||||
return m, nil
|
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 {
|
if m.viewMode {
|
||||||
return m.exitViewMode()
|
return m.exitViewMode()
|
||||||
}
|
}
|
||||||
|
|
@ -1022,6 +1024,7 @@ func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.cleanupImageOverlay()
|
||||||
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
|
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
|
||||||
return m, tea.ExecProcess(command, func(err error) tea.Msg {
|
return m, tea.ExecProcess(command, func(err error) tea.Msg {
|
||||||
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
|
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
|
||||||
|
|
@ -1041,6 +1044,7 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.cleanupImageOverlay()
|
||||||
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
|
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
|
||||||
return m, tea.ExecProcess(command, func(err error) tea.Msg {
|
return m, tea.ExecProcess(command, func(err error) tea.Msg {
|
||||||
return opMsg{kind: opEdit, sourcePath: selected.Path, err: err}
|
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"
|
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 {
|
func (m *Model) hoverIndexFor(pane PaneID) int {
|
||||||
if m.hover.ok && m.hover.pane == pane {
|
if m.hover.ok && m.hover.pane == pane {
|
||||||
return m.hover.index
|
return m.hover.index
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue