Skip to content

Commit 4d4e808

Browse files
engalarako
authored andcommitted
feat(tui): click FROM FILE path to open single image overlay, remove inline preview
1 parent 2f597f2 commit 4d4e808

5 files changed

Lines changed: 113 additions & 31 deletions

File tree

tui/app.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,30 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
167167
a.syncHintBar()
168168
return a, nil
169169

170+
case OpenImageOverlayMsg:
171+
w, h := a.width, a.height
172+
paths := msg.Paths
173+
title := msg.Title
174+
return a, func() tea.Msg {
175+
innerW := w - 4
176+
innerH := h - 4
177+
if innerW < 20 {
178+
innerW = 20
179+
}
180+
if innerH < 5 {
181+
innerH = 5
182+
}
183+
perImg := innerH / len(paths)
184+
if perImg < 1 {
185+
perImg = 1
186+
}
187+
content := renderImagesWithSize(paths, innerW, perImg)
188+
if content == "" {
189+
content = "(no image rendered — set MXCLI_IMAGE_PROTOCOL or install chafa)"
190+
}
191+
return OpenOverlayMsg{Title: title, Content: content}
192+
}
193+
170194
case CompareLoadMsg:
171195
a.compare.SetContent(msg.Side, msg.Title, msg.NodeType, msg.Content)
172196
return a, nil

tui/image_render.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tui
22

33
import (
44
"encoding/base64"
5+
"fmt"
56
"os"
67
"os/exec"
78
"strings"
@@ -62,10 +63,10 @@ func renderImageIterm2(path string) string {
6263
return "\x1b]1337;File=inline=1;width=auto:" + encoded + "\a"
6364
}
6465

65-
// renderImageChafa renders an image file using chafa, which auto-detects the
66-
// best protocol for the current terminal (including Sixel via DA1 query).
67-
func renderImageChafa(path string) string {
68-
out, err := exec.Command("chafa", "--format=symbols", path).Output()
66+
// renderImageChafa renders an image file using chafa sized to width x height cells.
67+
func renderImageChafa(path string, width, height int) string {
68+
size := fmt.Sprintf("%dx%d", width, height)
69+
out, err := exec.Command("chafa", "--format=symbols", "--size="+size, path).Output()
6970
if err != nil {
7071
return ""
7172
}
@@ -74,23 +75,25 @@ func renderImageChafa(path string) string {
7475

7576
// renderImageSixel renders an image file using the Sixel protocol via img2sixel.
7677
// Falls back to chafa --format=sixel if img2sixel is not available.
77-
func renderImageSixel(path string) string {
78+
func renderImageSixel(path string, width, height int) string {
79+
size := fmt.Sprintf("%dx%d", width, height)
7880
if p, err := exec.LookPath("img2sixel"); err == nil {
79-
out, err := exec.Command(p, path).Output()
81+
out, err := exec.Command(p, "--width="+fmt.Sprintf("%d", width), path).Output()
8082
if err == nil {
8183
return string(out)
8284
}
8385
}
84-
out, err := exec.Command("chafa", "--format=sixel", path).Output()
86+
out, err := exec.Command("chafa", "--format=sixel", "--size="+size, path).Output()
8587
if err != nil {
8688
return ""
8789
}
8890
return string(out)
8991
}
9092

91-
// renderImages renders a list of image file paths using the detected terminal protocol.
92-
// Returns empty string if the terminal does not support inline images.
93-
func renderImages(paths []string) string {
93+
// renderImagesWithSize renders a list of image paths using the detected terminal protocol,
94+
// constraining each image to width × perImgHeight cells.
95+
// Multiple images are stacked vertically.
96+
func renderImagesWithSize(paths []string, width, perImgHeight int) string {
9497
protocol := detectImageProtocol()
9598
if protocol == "" || len(paths) == 0 {
9699
return ""
@@ -103,9 +106,9 @@ func renderImages(paths []string) string {
103106
case "iterm2":
104107
sb.WriteString(renderImageIterm2(p))
105108
case "sixel":
106-
sb.WriteString(renderImageSixel(p))
109+
sb.WriteString(renderImageSixel(p, width, perImgHeight))
107110
case "chafa":
108-
sb.WriteString(renderImageChafa(p))
111+
sb.WriteString(renderImageChafa(p, width, perImgHeight))
109112
}
110113
sb.WriteString("\n")
111114
}

tui/miller.go

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ const (
2121
type PreviewPane struct {
2222
childColumn *Column
2323
content string
24-
imageContent string // inline image sequences — excluded from yank
24+
imagePaths []string // source image file paths for lazy rendering
2525
contentLines []string // split content for scrolling
2626
highlighted string
2727
mode PreviewMode
2828
loading bool
2929
scrollOffset int
30+
3031
}
3132

3233
// navEntry stores one level of the navigation stack for drill-in / go-back.
@@ -114,7 +115,7 @@ func (m MillerView) Update(msg tea.Msg) (MillerView, tea.Cmd) {
114115
Trace("miller: PreviewReady key=%q highlight=%q len=%d", msg.NodeKey, msg.HighlightType, len(msg.Content))
115116
m.preview.loading = false
116117
m.preview.content = msg.Content
117-
m.preview.imageContent = msg.ImageContent
118+
m.preview.imagePaths = msg.ImagePaths
118119
m.preview.contentLines = strings.Split(msg.Content, "\n")
119120
m.preview.highlighted = msg.HighlightType
120121
m.preview.childColumn = nil
@@ -183,7 +184,7 @@ func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.C
183184
col.SetItems(treeNodesToItems(node.Children))
184185
m.preview.childColumn = &col
185186
m.preview.content = ""
186-
m.preview.imageContent = ""
187+
m.preview.imagePaths = nil
187188
m.preview.contentLines = nil
188189
m.preview.loading = false
189190
m.preview.scrollOffset = 0
@@ -199,7 +200,7 @@ func (m MillerView) handleCursorChanged(msg CursorChangedMsg) (MillerView, tea.C
199200
return m, cmd
200201
}
201202
m.preview.content = ""
202-
m.preview.imageContent = ""
203+
m.preview.imagePaths = nil
203204
m.preview.contentLines = nil
204205
m.preview.loading = false
205206
return m, nil
@@ -393,8 +394,14 @@ func (m MillerView) renderPreview(previewWidth int) string {
393394
if m.preview.mode == PreviewNDSL {
394395
modeLabel = "NDSL"
395396
}
397+
if len(m.preview.imagePaths) > 0 {
398+
modeLabel += " 🖼 click path to view"
399+
}
396400

397401
contentHeight := m.height - 1 // reserve 1 line for header
402+
if contentHeight < 1 {
403+
contentHeight = 1
404+
}
398405
srcLines := m.preview.contentLines
399406
totalSrc := len(srcLines)
400407

@@ -461,14 +468,10 @@ func (m MillerView) renderPreview(previewWidth int) string {
461468
out.WriteByte('\n')
462469
}
463470

464-
rendered := lipgloss.NewStyle().
471+
return lipgloss.NewStyle().
465472
Width(previewWidth).
466473
MaxHeight(m.height).
467474
Render(out.String())
468-
if m.preview.imageContent != "" {
469-
rendered += "\n" + m.preview.imageContent
470-
}
471-
return rendered
472475
}
473476

474477
return lipgloss.NewStyle().
@@ -628,6 +631,19 @@ func (m MillerView) handleMouse(msg tea.MouseMsg) (MillerView, tea.Cmd) { //noli
628631
return m, nil
629632

630633
case zonePreview:
634+
// If imagecollection, click a FROM FILE line → open that image in overlay
635+
if m.preview.childColumn == nil && len(m.preview.imagePaths) > 0 {
636+
// Y=0 tabbar, Y=1 MDL header, Y=2+ content (0-indexed visual lines)
637+
clickedVLine := msg.Y - 2
638+
path := findImagePathAtClick(m.preview.contentLines, m.preview.imagePaths,
639+
clickedVLine, m.preview.scrollOffset)
640+
if path != "" {
641+
return m, func() tea.Msg {
642+
return OpenImageOverlayMsg{Title: "Image Preview", Paths: []string{path}}
643+
}
644+
}
645+
return m, nil
646+
}
631647
// Click preview child item → drill in, then select the clicked item
632648
if m.preview.childColumn != nil {
633649
clickedIdx := m.preview.childColumn.HitTestIndex(msg.Y)
@@ -748,7 +764,7 @@ func (m *MillerView) updateFocusStyles() {
748764
func (m *MillerView) clearPreview() {
749765
m.preview.childColumn = nil
750766
m.preview.content = ""
751-
m.preview.imageContent = ""
767+
m.preview.imagePaths = nil
752768
m.preview.contentLines = nil
753769
m.preview.loading = false
754770
m.preview.scrollOffset = 0
@@ -811,3 +827,37 @@ func cloneItems(items []ColumnItem) []ColumnItem {
811827
copy(cloned, items)
812828
return cloned
813829
}
830+
831+
// findImagePathAtClick maps a clicked visual line (0-indexed relative to content area)
832+
// plus the current scroll offset to an image file path in imagePaths.
833+
// Returns "" if no FROM FILE line is found near the click.
834+
func findImagePathAtClick(contentLines, imagePaths []string, clickedVLine, scrollOffset int) string {
835+
// Approximate source line: each long FROM FILE line typically wraps to ~2 visual lines.
836+
// Search a window of ±3 source lines around the estimate to handle wrapping.
837+
approx := clickedVLine + scrollOffset
838+
for delta := 0; delta <= 3; delta++ {
839+
for _, sign := range []int{0, 1, -1} {
840+
srcIdx := approx + sign*delta
841+
if srcIdx < 0 || srcIdx >= len(contentLines) {
842+
continue
843+
}
844+
plain := stripANSI(contentLines[srcIdx])
845+
i := strings.Index(plain, "FROM FILE '")
846+
if i == -1 {
847+
continue
848+
}
849+
rest := plain[i+len("FROM FILE '"):]
850+
end := strings.Index(rest, "'")
851+
if end == -1 {
852+
continue
853+
}
854+
foundPath := rest[:end]
855+
for _, p := range imagePaths {
856+
if p == foundPath {
857+
return p
858+
}
859+
}
860+
}
861+
}
862+
return ""
863+
}

tui/preview.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ const (
4646
// PreviewResult holds cached preview content.
4747
type PreviewResult struct {
4848
Content string
49-
ImageContent string // rendered inline images (excluded from yank)
50-
HighlightType string // "mdl" / "ndsl" / "plain"
49+
ImagePaths []string // image file paths for lazy rendering (excluded from yank)
50+
HighlightType string // "mdl" / "ndsl" / "plain"
5151
}
5252

5353
// PreviewReadyMsg is sent when async preview content is available.
5454
type PreviewReadyMsg struct {
5555
Content string
56-
ImageContent string // rendered inline images (excluded from yank)
56+
ImagePaths []string // image file paths for lazy rendering (excluded from yank)
5757
HighlightType string
5858
NodeKey string
5959
}
@@ -133,21 +133,20 @@ func (e *PreviewEngine) RequestPreview(nodeType, qualifiedName string, mode Prev
133133
highlighted = DetectAndHighlight(content)
134134
}
135135

136-
// Render inline images separately so yank only copies MDL text.
137-
var imageContent string
136+
// Extract image paths for lazy rendering (size-aware, excluded from yank).
137+
var imagePaths []string
138138
if strings.ToLower(nodeType) == "imagecollection" && mode == PreviewMDL {
139-
imagePaths := extractImagePaths(content)
140-
imageContent = renderImages(imagePaths)
139+
imagePaths = extractImagePaths(content)
141140
}
142141

143-
result := PreviewResult{Content: highlighted, ImageContent: imageContent, HighlightType: highlightType}
142+
result := PreviewResult{Content: highlighted, ImagePaths: imagePaths, HighlightType: highlightType}
144143
e.mu.Lock()
145144
e.cache[key] = result
146145
e.mu.Unlock()
147146

148147
return PreviewReadyMsg{
149148
Content: highlighted,
150-
ImageContent: imageContent,
149+
ImagePaths: imagePaths,
151150
HighlightType: highlightType,
152151
NodeKey: key,
153152
}

tui/tab.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ type OpenOverlayMsg struct {
1414
Content string
1515
}
1616

17+
// OpenImageOverlayMsg requests a full-size image overlay for a list of image paths.
18+
type OpenImageOverlayMsg struct {
19+
Title string
20+
Paths []string
21+
}
22+
1723
// ParseTree parses JSON from mxcli project-tree output.
1824
func ParseTree(jsonStr string) ([]*TreeNode, error) {
1925
var nodes []*TreeNode

0 commit comments

Comments
 (0)