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
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ 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)
- **Fullscreen HD** — Press `f` to view the screenshot fullscreen using your terminal's native graphics protocol (Kitty, iTerm2, WezTerm, Ghostty), navigate between previews with `↑/↓`
- **Crash detection** — Automatically detects when a preview crashes and shows the root cause error in the preview panel
- **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
- **Details** — See fully qualified name, file path, line number, and `@Preview` parameters
- **Composable count** — Shows total `@Composable` functions per module, helping identify preview coverage gaps
- **Stale detection** — Warns when source files are newer than the installed APK

## Install
Expand Down Expand Up @@ -95,8 +97,14 @@ compose-preview --run SplashScreenPreview
# Take a screenshot of a preview (saves to preview.png):
compose-preview --screenshot SplashScreenPreview

# Custom output file and delay (default: 3s):
compose-preview --screenshot SplashScreenPreview --output splash.png --delay 5
# Custom output file and render delay (default: 1s):
compose-preview --screenshot SplashScreenPreview --output splash.png --delay 3

# Clear cached screenshots:
compose-preview --clear

# Dismiss "built for older Android" dialog automatically:
compose-preview --dismiss-dialog
```

### Layout
Expand Down Expand Up @@ -136,7 +144,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) |
| `f` | Fullscreen HD preview with native terminal graphics — `↑/↓` to navigate between previews |
| `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 @@ -170,10 +178,14 @@ 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.
Press `f` to enter **fullscreen HD mode** using your terminal's native graphics protocol for pixel-perfect quality. Use `↑/↓` to navigate between previews — cached screenshots display instantly, uncached ones are captured on the fly. Supported terminals: Kitty, iTerm2, WezTerm, Ghostty. Other terminals fall back to half-block rendering. Press any other key to return to the TUI.

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

### Crash detection

When a preview crashes on the device, the error is automatically detected from logcat and displayed in the preview panel with the root cause exception and source location. This replaces the blank/broken screenshot you would otherwise see.

### 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 Expand Up @@ -215,7 +227,7 @@ debugImplementation("androidx.compose.ui:ui-tooling")
1. **Discover** — Walks your Gradle project to find modules via `build.gradle.kts` files
2. **Scan** — Parses `.kt` files for `@Preview` annotations using regex (fast, no compilation needed)
3. **Resolve** — Extracts package name, JVM class name (`FileNameKt`), function name, and preview parameters
4. **Launch** — Sends `adb shell am start` with the composable FQN to `PreviewActivity`
4. **Launch** — Sends `adb shell am start -W` with the composable FQN to `PreviewActivity` (waits for Activity to be displayed)
5. **Detect** — Auto-discovers the installed app package, trying all flavor variants

Works with both pure Android and Kotlin Multiplatform (KMP) projects.
Expand Down
37 changes: 33 additions & 4 deletions cmd/compose-preview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"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"
"github.com/ignaciotcrespo/compose-preview-cli/internal/ui/screenshot"
)

// version is set by goreleaser via ldflags.
Expand All @@ -33,10 +34,12 @@ func main() {
// Check for flags
webMode := false
listMode := false
clearCache := false
dismissDialog := false
runPreview := ""
screenshotPreview := ""
screenshotOutput := "preview.png"
screenshotDelay := 3
screenshotDelay := 1
webPort := 9999
args := []string{}
for i := 1; i < len(os.Args); i++ {
Expand All @@ -62,6 +65,10 @@ func main() {
os.Exit(1)
}
screenshotDelay = d
} else if arg == "--clear" {
clearCache = true
} else if arg == "--dismiss-dialog" {
dismissDialog = true
} else if (arg == "--port" || arg == "-p") && i+1 < len(os.Args) {
i++
p, err := strconv.Atoi(os.Args[i])
Expand Down Expand Up @@ -99,6 +106,16 @@ func main() {
return
}

if clearCache {
dir := screenshot.SharedDir
if err := os.RemoveAll(dir); err != nil {
fmt.Fprintf(os.Stderr, "Error clearing cache: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Cleared screenshot cache: %s\n", dir)
return
}

if listMode {
runListMode(root)
return
Expand All @@ -120,12 +137,24 @@ func main() {
fmt.Fprintf(os.Stderr, "Found %d previews across %d modules\n", len(result.AllPreviews), len(result.Modules))

if len(result.AllPreviews) == 0 {
fmt.Fprintf(os.Stderr, "No @Preview composables found.\n")
totalComposables := 0
for _, mod := range result.Modules {
totalComposables += mod.ComposableCount
}
if totalComposables > 0 {
fmt.Fprintf(os.Stderr, "No @Preview composables found, but %d @Composable functions exist.\n", totalComposables)
fmt.Fprintf(os.Stderr, "Add @Preview annotations to enable preview browsing.\n")
} else {
fmt.Fprintf(os.Stderr, "No @Preview or @Composable functions found.\n")
}
os.Exit(0)
}

// Launch TUI
model := ui.NewModel(result, root)
model := ui.NewModel(result, root, ui.Options{
DismissDialog: dismissDialog,
ScreenshotDelay: time.Duration(screenshotDelay) * time.Second,
})
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithReportFocus())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down Expand Up @@ -268,7 +297,7 @@ func resolvePreview(root, query string) resolvedPreview {
func launchPreview(r resolvedPreview) {
fmt.Fprintf(os.Stderr, "Launching %s on %s...\n", r.preview.FunctionName, r.serial)
for _, pkg := range r.packages {
if err := adb.LaunchPreview(r.serial, pkg, r.preview.FQN); err == nil {
if err := adb.LaunchPreview(r.serial, pkg, r.preview.FQN, false); err == nil {
fmt.Fprintf(os.Stderr, "Launched: %s (%s)\n", r.preview.FunctionName, pkg)
return
}
Expand Down
97 changes: 95 additions & 2 deletions internal/adb/adb.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package adb

import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)

// Device represents a connected Android device.
Expand Down Expand Up @@ -155,14 +157,15 @@ func CaptureScreenshot(serial string) ([]byte, error) {

// LaunchPreview starts PreviewActivity on the device with the given composable FQN.
// It force-stops the app first to ensure the new preview is rendered fresh.
func LaunchPreview(serial, appPackage, composableFQN string) error {
// If dismissDialog is true, it sends key events to dismiss the "built for older Android" dialog.
func LaunchPreview(serial, appPackage, composableFQN string, dismissDialog bool) error {
// Force-stop the app so PreviewActivity restarts with the new composable
exec.Command("adb", "-s", serial, "shell", "am", "force-stop", appPackage).Run()

activity := appPackage + "/androidx.compose.ui.tooling.PreviewActivity"
args := []string{
"-s", serial,
"shell", "am", "start",
"shell", "am", "start", "-W",
"-n", activity,
"--es", "composable", composableFQN,
}
Expand All @@ -176,5 +179,95 @@ func LaunchPreview(serial, appPackage, composableFQN string) error {
if strings.Contains(outStr, "Error") {
return fmt.Errorf("%s", strings.TrimSpace(outStr))
}

if dismissDialog {
// Dismiss "built for an older version of Android" dialog if it appears.
// Send two ENTERs: first focuses the OK button, second confirms it.
go func() {
time.Sleep(500 * time.Millisecond)
exec.Command("adb", "-s", serial, "shell", "input", "keyevent", "KEYCODE_ENTER").Run()
time.Sleep(200 * time.Millisecond)
exec.Command("adb", "-s", serial, "shell", "input", "keyevent", "KEYCODE_ENTER").Run()
}()
}

return nil
}

// ClearLogcat clears the logcat buffer.
func ClearLogcat(serial string) {
exec.Command("adb", "-s", serial, "logcat", "-c").Run()
}

// CheckPreviewCrash reads logcat for crash info.
// Returns the crash message if found, or empty string.
// Call ClearLogcat before launching the preview, then call this after waiting.
func CheckPreviewCrash(serial string) string {
// Read logcat for crashes — filter by AndroidRuntime (crash tag)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "adb", "-s", serial, "logcat", "-d",
"-s", "AndroidRuntime:E")
out, err := cmd.Output()
if err != nil {
return ""
}

logcat := string(out)
if !strings.Contains(logcat, "FATAL EXCEPTION") {
return ""
}

// Find the deepest "Caused by:" line — that's the root cause.
// Also collect the line right after it (the first "at ..." gives context).
lines := strings.Split(logcat, "\n")
lastCausedBy := ""
lastCausedByNext := ""
for i, line := range lines {
cleaned := stripLogcatPrefix(line)
if strings.HasPrefix(cleaned, "Caused by:") {
lastCausedBy = cleaned
if i+1 < len(lines) {
lastCausedByNext = strings.TrimSpace(stripLogcatPrefix(lines[i+1]))
}
}
}

if lastCausedBy == "" {
// No "Caused by", use the main exception line
for _, line := range lines {
cleaned := stripLogcatPrefix(line)
if strings.Contains(cleaned, "Exception") || strings.Contains(cleaned, "Error") {
if !strings.Contains(cleaned, "FATAL EXCEPTION") && !strings.Contains(cleaned, "Process:") {
lastCausedBy = cleaned
break
}
}
}
}

if lastCausedBy == "" {
return ""
}

// Format: "ExceptionType: message"
// Strip the "Caused by: " prefix for cleaner display
result := strings.TrimPrefix(lastCausedBy, "Caused by: ")
if lastCausedByNext != "" && strings.HasPrefix(lastCausedByNext, "at ") {
result += "\n" + lastCausedByNext
}
return result
}

// stripLogcatPrefix removes the logcat metadata prefix from a line.
// e.g. "04-18 01:43:02.095 4918 4918 E AndroidRuntime: actual message"
// becomes "actual message"
func stripLogcatPrefix(line string) string {
line = strings.TrimSpace(line)
// Logcat format: "date time pid tid level tag: message"
// The tag for crashes is "AndroidRuntime", look for that marker
if idx := strings.Index(line, "AndroidRuntime:"); idx >= 0 {
return strings.TrimSpace(line[idx+len("AndroidRuntime:"):])
}
return line
}
56 changes: 38 additions & 18 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ func Scan(root string) ScanResult {

var allPreviews []PreviewFunc
for i := range modules {
modules[i].Previews = scanModule(modules[i].Path, modules[i].Name)
allPreviews = append(allPreviews, modules[i].Previews...)
previews, count := scanModule(modules[i].Path, modules[i].Name)
modules[i].Previews = previews
modules[i].ComposableCount = count
allPreviews = append(allPreviews, previews...)
}

return ScanResult{
Expand Down Expand Up @@ -79,25 +81,28 @@ func discoverModules(root string) []Module {
}

// scanModule scans all .kt files in a module's src directory for @Preview functions.
func scanModule(modulePath, moduleName string) []PreviewFunc {
// Returns previews found and total composable count.
func scanModule(modulePath, moduleName string) ([]PreviewFunc, int) {
srcDir := filepath.Join(modulePath, "src")
if _, err := os.Stat(srcDir); os.IsNotExist(err) {
return nil
return nil, 0
}

var previews []PreviewFunc
composableCount := 0
filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".kt") {
return nil
}
found := scanFile(path, moduleName)
found, count := scanFile(path, moduleName)
previews = append(previews, found...)
composableCount += count
return nil
})
return previews
return previews, composableCount
}

// jvmClassName returns the JVM class name for top-level functions in a Kotlin file.
Expand All @@ -109,22 +114,25 @@ func jvmClassName(filePath string) string {
}

// scanFile parses a single Kotlin file for @Preview annotated functions.
func scanFile(path, moduleName string) []PreviewFunc {
// Returns previews found and total @Composable function count.
func scanFile(path, moduleName string) ([]PreviewFunc, int) {
f, err := os.Open(path)
if err != nil {
return nil
return nil, 0
}
defer f.Close()

className := jvmClassName(path)

var (
previews []PreviewFunc
pkg string
inPreview bool
previewLine int
parenDepth int
annotationText strings.Builder
previews []PreviewFunc
composableCount int
pkg string
inPreview bool
inComposable bool
previewLine int
parenDepth int
annotationText strings.Builder
)

scanner := bufio.NewScanner(f)
Expand Down Expand Up @@ -164,14 +172,26 @@ func scanFile(path, moduleName string) []PreviewFunc {
continue
}

// Skip @Composable between @Preview and fun
if inPreview && composableRe.MatchString(line) {
continue
// Track @Composable annotations (for counting all composables)
if composableRe.MatchString(line) {
inComposable = true
if inPreview {
continue
}
}

// Count @Composable functions (with or without @Preview)
if !inPreview && inComposable {
if funRe.MatchString(line) {
composableCount++
inComposable = false
}
}

// Look for function declaration after @Preview
if inPreview {
if m := funRe.FindStringSubmatch(line); m != nil {
composableCount++ // previews are also composables
funcName := m[1]
// FQN uses JVM class name: package.FileNameKt.FunctionName
fqn := className + "." + funcName
Expand Down Expand Up @@ -208,7 +228,7 @@ func scanFile(path, moduleName string) []PreviewFunc {
}
}
}
return previews
return previews, composableCount
}

// parseParams extracts key=value pairs from an annotation string.
Expand Down
Loading
Loading