Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes
│ Params: showBackground=true, backgroundColor=0xFF111111 │
╰─────────────────────────────────────────────────────────────────────────────────────╯
● Launched: LoginScreenEmptyDarkPreview (com.example.app.dev)
enter run · s screenshot · w web · i install · / filter · d device · q quit
enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · q quit
```

## What it does
Expand All @@ -45,6 +45,7 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes
- **Search** — Live filter bar (`/`) matches preview names across all modules, counts update in real time
- **Run** — Launch any preview on a connected device via ADB with `Enter`
- **Screenshot** — Capture a preview screenshot (`s`) displayed directly in the terminal
- **Fullscreen Preview** — Press `f` to view the screenshot fullscreen using your terminal's native graphics protocol (Kitty, iTerm2, WezTerm, Ghostty)
- **HD Web Preview** — Press `w` to open a local web viewer in your browser with full-quality preview rendering
- **Install** — Trigger Gradle install tasks (`i`) with automatic variant detection (dev, qa, accept, production)
- **Device / Emulator picker** — Press `d` to select a connected device or launch an AVD emulator
Expand Down Expand Up @@ -121,7 +122,7 @@ compose-preview --screenshot SplashScreenPreview --output splash.png --delay 5
├──────────────────────────────────────────────────────────────────────────────┤
│ ⚠ sources changed since last build — press 'i' to install │ status
├──────────────────────────────────────────────────────────────────────────────┤
│ enter run · s screenshot · w web · i install · / filter · d device · q quit │ help
│ enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · q quit │ help
└──────────────────────────────────────────────────────────────────────────────┘
```

Expand All @@ -135,6 +136,7 @@ compose-preview --screenshot SplashScreenPreview --output splash.png --delay 5
| `Esc` | Clear filter and exit search |
| `j/k` or `↑/↓` | Navigate items in focused panel |
| `s` | Capture screenshot of the selected preview |
| `f` | View screenshot fullscreen with native terminal graphics (Kitty/iTerm2/Ghostty/WezTerm) |
| `w` | Toggle HD web preview viewer in browser |
| `i` | Install APK via Gradle (auto-detects build variants) |
| `d` | Open device / emulator picker |
Expand Down Expand Up @@ -168,6 +170,10 @@ Press `s` to capture a screenshot of the selected preview. The screenshot is ren

Running a preview with `Enter` also auto-captures a screenshot after a short delay.

Press `f` to view the screenshot **fullscreen** using your terminal's native graphics protocol for pixel-perfect quality. Supported terminals: Kitty, iTerm2, WezTerm, Ghostty. Other terminals fall back to half-block rendering. Press any key to return to the TUI.

The `--screenshot` CLI command also displays the image inline using the native graphics protocol.

### HD Web Preview

The terminal screenshot is low resolution. For full-quality rendering, press `w` to start a local web viewer. This opens your browser with an HD version of the preview, served from a local web server. Press `w` again to stop the server.
Expand Down
6 changes: 6 additions & 0 deletions cmd/compose-preview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/ignaciotcrespo/compose-preview-cli/internal/scanner"
"github.com/ignaciotcrespo/compose-preview-cli/internal/server"
"github.com/ignaciotcrespo/compose-preview-cli/internal/ui"
"github.com/ignaciotcrespo/compose-preview-cli/internal/ui/imgrender"
)

// version is set by goreleaser via ldflags.
Expand Down Expand Up @@ -299,6 +300,11 @@ func runScreenshotMode(root, query, output string, delay int) {
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Screenshot saved to %s\n", output)

// Display inline using the best available terminal graphics protocol
proto := imgrender.DetectGraphics()
w, h := imgrender.TerminalSize(os.Stdout)
fmt.Print(proto.Render(png, w, h))
}

func findAppApplicationId(modules []scanner.Module, projectRoot string) (string, string) {
Expand Down
97 changes: 97 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"time"
"unsafe"

"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -267,6 +270,9 @@
m.needsBuild, m.buildWarning = gradle.NeedsBuild(m.appModulePath, m.projectRoot)
return m, nil

case fullscreenDoneMsg:
return m, nil

case screenshotMsg:
m.capturing = false
if msg.err != nil {
Expand Down Expand Up @@ -498,6 +504,11 @@
return m, nil
}

// "f" shows the screenshot fullscreen using the terminal's native graphics protocol
if key == "f" {
return m, m.fullscreenPreview()
}

// "/" activates search bar
if key == "/" {
m.searchActive = true
Expand Down Expand Up @@ -834,6 +845,92 @@
m.statusMsg = fmt.Sprintf("Opened: %s", tmpFile)
}

// fullscreenPreview temporarily exits the TUI and shows the screenshot
// fullscreen using the terminal's native graphics protocol.
func (m *Model) fullscreenPreview() tea.Cmd {
previews := m.filteredPreviews()
if m.state.PreviewSel >= len(previews) {
return nil
}
fqn := previews[m.state.PreviewSel].FQN
entry := m.screenshotCache.Get(fqn)
if entry == nil {
m.errorMsg = "No screenshot cached — press 's' first"
return nil
}
name := previews[m.state.PreviewSel].FunctionName
return tea.Exec(&fullscreenImgCmd{pngData: entry.PNGData, name: name}, func(err error) tea.Msg {
return fullscreenDoneMsg{}
})
}

// fullscreenDoneMsg is sent when the fullscreen preview is dismissed.
type fullscreenDoneMsg struct{}

// fullscreenImgCmd implements tea.ExecCommand to display an image fullscreen.
type fullscreenImgCmd struct {
pngData []byte
name string
stdin io.Reader
stdout io.Writer
stderr io.Writer
}

func (c *fullscreenImgCmd) SetStdin(r io.Reader) { c.stdin = r }
func (c *fullscreenImgCmd) SetStdout(w io.Writer) { c.stdout = w }
func (c *fullscreenImgCmd) SetStderr(w io.Writer) { c.stderr = w }

func (c *fullscreenImgCmd) Run() error {
proto := imgrender.DetectGraphics()

// Clear screen
fmt.Fprint(c.stdout, "\033[2J\033[H")

// Title bar
title := fmt.Sprintf(" %s — %s (press any key to return)", c.name, proto.Name())
fmt.Fprintln(c.stdout, title)
fmt.Fprintln(c.stdout)

// Render image using full terminal width, leaving room for title
w, h := imgrender.TerminalSize(c.stdout)
rendered := proto.Render(c.pngData, w, h-3)
fmt.Fprint(c.stdout, rendered)

// Wait for any key — put stdin in raw mode so we don't need enter
if f, ok := c.stdin.(*os.File); ok {
if oldState, err := makeRaw(f.Fd()); err == nil {
buf := make([]byte, 1)
f.Read(buf)
restoreTerminal(f.Fd(), oldState)
}
}
return nil
}

// makeRaw puts the terminal into raw mode and returns the previous state.
func makeRaw(fd uintptr) (syscall.Termios, error) {
var oldState syscall.Termios
if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd,
uintptr(syscall.TIOCGETA), uintptr(unsafe.Pointer(&oldState)), 0, 0, 0); err != 0 {

Check failure on line 914 in internal/ui/app.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: syscall.TIOCGETA
return oldState, err
}
newState := oldState
newState.Lflag &^= syscall.ICANON | syscall.ECHO
newState.Cc[syscall.VMIN] = 1
newState.Cc[syscall.VTIME] = 0
if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd,
uintptr(syscall.TIOCSETA), uintptr(unsafe.Pointer(&newState)), 0, 0, 0); err != 0 {

Check failure on line 922 in internal/ui/app.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: syscall.TIOCSETA
return oldState, err
}
return oldState, nil
}

// restoreTerminal restores the terminal to the given state.
func restoreTerminal(fd uintptr, state syscall.Termios) {
syscall.Syscall6(syscall.SYS_IOCTL, fd,
uintptr(syscall.TIOCSETA), uintptr(unsafe.Pointer(&state)), 0, 0, 0)

Check failure on line 931 in internal/ui/app.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: syscall.TIOCSETA
}

// captureScreenshot takes a screenshot of the current preview.
func (m *Model) captureScreenshot() tea.Cmd {
previews := m.filteredPreviews()
Expand Down
45 changes: 39 additions & 6 deletions internal/ui/imgrender/detect.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,49 @@
package imgrender

import "os"
import (
"os"
"strings"
)

// detect returns the best available image rendering protocol for the current terminal.
// To add support for a new terminal:
// 1. Create a new file (e.g., protocol_foo.go) implementing Protocol
// 2. Add detection logic here
//
// NOTE: Inside Bubbletea's alt-screen mode, graphics protocols (Kitty, iTerm2)
// break the panel layout. The TUI always uses half-block rendering.
// Use DetectGraphics() for standalone/non-TUI contexts (e.g., --screenshot).
func detect() Protocol {
// NOTE: iTerm2 and Kitty inline image protocols don't work inside
// Bubbletea alt-screen mode — the escape sequences break the panel layout.
// Half-block is the only reliable option inside a TUI framework.
// The protocol files are kept for future use (e.g., standalone image viewer).
_ = os.Getenv("TERM_PROGRAM") // reserved for future protocol detection
return &halfBlockProtocol{}
}

// DetectGraphics returns the best graphics protocol for the current terminal,
// for use outside the TUI (e.g., printing an image to stdout).
// Falls back to half-block if no graphics protocol is detected.
func DetectGraphics() Protocol {
term := os.Getenv("TERM_PROGRAM")
termInfo := os.Getenv("TERM")

// Kitty detection: TERM=xterm-kitty or TERM_PROGRAM=kitty
if term == "kitty" || strings.Contains(termInfo, "kitty") {
return &kittyProtocol{}
}

// iTerm2 detection (also covers WezTerm which sets LC_TERMINAL=iTerm2)
lcTerminal := os.Getenv("LC_TERMINAL")
if term == "iTerm.app" || lcTerminal == "iTerm2" {
return &iterm2Protocol{}
}

// WezTerm also supports iTerm2 inline images
if term == "WezTerm" {
return &iterm2Protocol{}
}

// Ghostty supports Kitty graphics protocol
if term == "ghostty" {
return &kittyProtocol{}
}

return &halfBlockProtocol{}
}
40 changes: 40 additions & 0 deletions internal/ui/imgrender/imgrender.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"bytes"
"image"
_ "image/png"
"io"
"os"
"syscall"
"unsafe"

"golang.org/x/image/draw"
)
Expand Down Expand Up @@ -36,6 +40,42 @@
return active.Name()
}

// IsGraphicsProtocol returns true if the active protocol uses terminal graphics
// escape sequences (Kitty, iTerm2) that would be corrupted by lipgloss processing.
// When true, callers should place the rendered output directly into the view
// without passing it through lipgloss styling functions.
func IsGraphicsProtocol() bool {
switch active.(type) {
case *kittyProtocol, *iterm2Protocol:
return true
default:
return false
}
}

// TerminalSize returns the terminal width and height from the given writer,
// falling back to 80x24 if detection fails.
func TerminalSize(w io.Writer) (int, int) {
fd := uintptr(syscall.Stdout)
if f, ok := w.(*os.File); ok {
fd = f.Fd()
}
type winsize struct {
Row, Col, Xpixel, Ypixel uint16
}
var ws winsize
_, _, _ = syscall.Syscall(syscall.SYS_IOCTL, fd,

Check failure on line 67 in internal/ui/imgrender/imgrender.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

undefined: syscall.SYS_IOCTL
uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws)))

Check failure on line 68 in internal/ui/imgrender/imgrender.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

not enough arguments in call to syscall.Syscall

Check failure on line 68 in internal/ui/imgrender/imgrender.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

undefined: syscall.TIOCGWINSZ
width, height := int(ws.Col), int(ws.Row)
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
return width, height
}

// resizeImage decodes PNG data and scales it to fit within the given pixel dimensions,
// preserving aspect ratio. Shared helper for all protocols.
func resizeImage(pngData []byte, targetW, targetH int) (*image.RGBA, error) {
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ func (m Model) renderHelp() string {
if m.webServer != nil {
webLabel = "w stop web"
}
parts := []string{"enter run", "s screenshot", webLabel, "i install", "/ filter", "d device", "q quit"}
parts := []string{"enter run", "s screenshot", "f fullscreen HD", webLabel, "i install", "/ filter", "d device", "q quit"}
return helpStyle.Render(" " + strings.Join(parts, " · "))
}

Expand Down
Loading