From 5e82242f038c57d29724b0a2a80c274f9d0d1e8a Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 23:07:39 -0300 Subject: [PATCH 1/6] detect errors --- cmd/compose-preview/main.go | 10 +++- internal/adb/adb.go | 94 ++++++++++++++++++++++++++++++++++++- internal/ui/app.go | 57 ++++++++++++++++++++-- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/cmd/compose-preview/main.go b/cmd/compose-preview/main.go index cdb3ce0..b8219d0 100644 --- a/cmd/compose-preview/main.go +++ b/cmd/compose-preview/main.go @@ -33,6 +33,7 @@ func main() { // Check for flags webMode := false listMode := false + dismissDialog := false runPreview := "" screenshotPreview := "" screenshotOutput := "preview.png" @@ -62,6 +63,8 @@ func main() { os.Exit(1) } screenshotDelay = d + } 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]) @@ -125,7 +128,10 @@ func main() { } // 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) @@ -268,7 +274,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 } diff --git a/internal/adb/adb.go b/internal/adb/adb.go index fe241e1..028629f 100644 --- a/internal/adb/adb.go +++ b/internal/adb/adb.go @@ -1,10 +1,12 @@ package adb import ( + "context" "fmt" "os" "os/exec" "strings" + "time" ) // Device represents a connected Android device. @@ -155,7 +157,8 @@ 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() @@ -176,5 +179,94 @@ 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 } + +// CheckPreviewCrash clears logcat, waits for the given duration, then checks +// if the app crashed. Returns the crash message if found, or empty string. +func CheckPreviewCrash(serial, appPackage string, wait time.Duration) string { + // Clear logcat before waiting + exec.Command("adb", "-s", serial, "logcat", "-c").Run() + + time.Sleep(wait) + + // Read logcat for crashes — filter by AndroidRuntime (crash tag) and the app package + 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 +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 1bf1867..831190c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -5,6 +5,7 @@ import ( "io" "os" "os/exec" + "strings" "path/filepath" "runtime" "syscall" @@ -118,8 +119,11 @@ type Model struct { // Screenshot cache and rendered preview screenshotCache *screenshot.Cache renderedPreview string // current rendered half-block image + previewErrors map[string]string // FQN → crash error message previewFQN string // FQN of the currently rendered preview capturing bool // screenshot capture in progress + dismissDialog bool // send key events to dismiss compatibility dialog + screenshotDelay time.Duration // wait time before capturing screenshot // Search bar (always visible at top) searchInput textinput.Model @@ -135,8 +139,14 @@ type Model struct { panelRegions map[types.PanelID]panel.Region } +// Options configures optional TUI behavior. +type Options struct { + DismissDialog bool // send key events to dismiss compatibility dialog after launch + ScreenshotDelay time.Duration // wait time before capturing screenshot (default 3s) +} + // NewModel creates the initial model from scan results. -func NewModel(result scanner.ScanResult, projectRoot string) Model { +func NewModel(result scanner.ScanResult, projectRoot string, opts ...Options) Model { // Filter out modules with no previews for display, but keep all var modulesWithPreviews []scanner.Module for _, m := range result.Modules { @@ -187,6 +197,14 @@ func NewModel(result scanner.ScanResult, projectRoot string) Model { si.Placeholder = "type to filter previews..." si.CharLimit = 100 + var opt Options + if len(opts) > 0 { + opt = opts[0] + } + if opt.ScreenshotDelay == 0 { + opt.ScreenshotDelay = 3 * time.Second + } + m := Model{ electronMode: os.Getenv("COMPOSE_PREVIEW_ELECTRON") == "1", state: controller.NewState(), @@ -200,6 +218,9 @@ func NewModel(result scanner.ScanResult, projectRoot string) Model { needsBuild: needsBuild, buildWarning: buildWarning, screenshotCache: screenshot.NewCache(), + previewErrors: make(map[string]string), + dismissDialog: opt.DismissDialog, + screenshotDelay: opt.ScreenshotDelay, searchInput: si, avds: avds, showDevicePicker: showPicker, @@ -276,8 +297,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case screenshotMsg: m.capturing = false if msg.err != nil { - m.errorMsg = fmt.Sprintf("Screenshot failed: %v", msg.err) + m.previewErrors[msg.fqn] = msg.err.Error() } else { + delete(m.previewErrors, msg.fqn) m.screenshotCache.Put(msg.fqn, msg.pngData) m.previewFQN = msg.fqn // Render will pick it up from the cache @@ -619,7 +641,7 @@ func (m *Model) launchPreview() tea.Cmd { // Try each installed variant until one works var lastErr error for _, pkg := range packages { - err := adb.LaunchPreview(serial, pkg, fqn) + err := adb.LaunchPreview(serial, pkg, fqn, m.dismissDialog) if err == nil { return adbLaunchMsg{preview: p.FunctionName + " (" + pkg + ")"} } @@ -799,6 +821,22 @@ func (m *Model) clickItem(pid types.PanelID, row int) { } } +// wordWrap breaks long lines at the given width, preserving existing newlines. +func wordWrap(s string, width int) string { + if width <= 0 { + return s + } + var result strings.Builder + for _, line := range strings.Split(s, "\n") { + for len(line) > width { + result.WriteString(" " + line[:width] + "\n") + line = line[width:] + } + result.WriteString(" " + line + "\n") + } + return strings.TrimRight(result.String(), "\n") +} + func clamp(v, lo, hi int) int { if v < lo { return lo @@ -967,8 +1005,11 @@ func (m *Model) delayedScreenshotCapture() tea.Cmd { m.screenshotCache.SignalCapturing(fqn) return func() tea.Msg { - // Wait for the preview to render on device - time.Sleep(2 * time.Second) + // Check for crashes while waiting for the preview to render + crash := adb.CheckPreviewCrash(serial, "", m.screenshotDelay) + if crash != "" { + return screenshotMsg{fqn: fqn, err: fmt.Errorf("%s", crash)} + } data, err := adb.CaptureScreenshot(serial) return screenshotMsg{fqn: fqn, pngData: data, err: err} } @@ -987,6 +1028,12 @@ func (m Model) currentPreviewScreenshot(width, height int) (string, string) { return " Capturing...", "" } + // Show crash error in the preview panel if the preview failed + if errMsg, ok := m.previewErrors[fqn]; ok { + wrapped := wordWrap(errMsg, width-4) + return errorStyle.Render(" Preview crashed:\n\n" + wrapped), "" + } + entry := m.screenshotCache.Get(fqn) if entry == nil { if m.capturing { From 90086075361b95391e71411f02765057d5e98575 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 23:26:29 -0300 Subject: [PATCH 2/6] improve delay --- cmd/compose-preview/main.go | 16 +++++++++++++++- internal/adb/adb.go | 17 +++++++++-------- internal/ui/app.go | 13 ++++++++++--- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/cmd/compose-preview/main.go b/cmd/compose-preview/main.go index b8219d0..3de5cc6 100644 --- a/cmd/compose-preview/main.go +++ b/cmd/compose-preview/main.go @@ -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. @@ -33,11 +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++ { @@ -63,6 +65,8 @@ 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) { @@ -102,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 diff --git a/internal/adb/adb.go b/internal/adb/adb.go index 028629f..1a5e902 100644 --- a/internal/adb/adb.go +++ b/internal/adb/adb.go @@ -165,7 +165,7 @@ func LaunchPreview(serial, appPackage, composableFQN string, dismissDialog bool) activity := appPackage + "/androidx.compose.ui.tooling.PreviewActivity" args := []string{ "-s", serial, - "shell", "am", "start", + "shell", "am", "start", "-W", "-n", activity, "--es", "composable", composableFQN, } @@ -194,15 +194,16 @@ func LaunchPreview(serial, appPackage, composableFQN string, dismissDialog bool) return nil } -// CheckPreviewCrash clears logcat, waits for the given duration, then checks -// if the app crashed. Returns the crash message if found, or empty string. -func CheckPreviewCrash(serial, appPackage string, wait time.Duration) string { - // Clear logcat before waiting +// ClearLogcat clears the logcat buffer. +func ClearLogcat(serial string) { exec.Command("adb", "-s", serial, "logcat", "-c").Run() +} - time.Sleep(wait) - - // Read logcat for crashes — filter by AndroidRuntime (crash tag) and the app package +// 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", diff --git a/internal/ui/app.go b/internal/ui/app.go index 831190c..540c125 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -202,7 +202,7 @@ func NewModel(result scanner.ScanResult, projectRoot string, opts ...Options) Mo opt = opts[0] } if opt.ScreenshotDelay == 0 { - opt.ScreenshotDelay = 3 * time.Second + opt.ScreenshotDelay = 1 * time.Second } m := Model{ @@ -638,6 +638,9 @@ func (m *Model) launchPreview() tea.Cmd { } } + // Clear logcat before launching so we can detect crashes afterwards + adb.ClearLogcat(serial) + // Try each installed variant until one works var lastErr error for _, pkg := range packages { @@ -1004,9 +1007,13 @@ func (m *Model) delayedScreenshotCapture() tea.Cmd { fqn := p.FQN m.screenshotCache.SignalCapturing(fqn) + delay := m.screenshotDelay return func() tea.Msg { - // Check for crashes while waiting for the preview to render - crash := adb.CheckPreviewCrash(serial, "", m.screenshotDelay) + // Wait for the preview to render (logcat was cleared before launch) + time.Sleep(delay) + + // Check logcat for crashes (no extra wait, crash already happened if it will) + crash := adb.CheckPreviewCrash(serial) if crash != "" { return screenshotMsg{fqn: fqn, err: fmt.Errorf("%s", crash)} } From 18c58462b91b74ec247aca50e47f23c5fcef7c52 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Fri, 17 Apr 2026 23:37:05 -0300 Subject: [PATCH 3/6] improvements --- cmd/compose-preview/main.go | 11 ++++++- internal/scanner/scanner.go | 56 ++++++++++++++++++++++---------- internal/scanner/scanner_test.go | 10 +++--- internal/scanner/types.go | 7 ++-- internal/ui/render.go | 6 +++- 5 files changed, 62 insertions(+), 28 deletions(-) diff --git a/cmd/compose-preview/main.go b/cmd/compose-preview/main.go index 3de5cc6..59a6ddb 100644 --- a/cmd/compose-preview/main.go +++ b/cmd/compose-preview/main.go @@ -137,7 +137,16 @@ 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) } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 3539538..f52f4b7 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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{ @@ -79,13 +81,15 @@ 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 @@ -93,11 +97,12 @@ func scanModule(modulePath, moduleName string) []PreviewFunc { 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. @@ -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) @@ -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 @@ -208,7 +228,7 @@ func scanFile(path, moduleName string) []PreviewFunc { } } } - return previews + return previews, composableCount } // parseParams extracts key=value pairs from an annotation string. diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 051dd90..c7dd3e3 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -21,7 +21,7 @@ fun MyButtonPreview() { } `), 0644) - previews := scanFile(kt, ":app") + previews, _ := scanFile(kt, ":app") if len(previews) != 1 { t.Fatalf("expected 1 preview, got %d", len(previews)) } @@ -49,7 +49,7 @@ fun CardPreview() { } `), 0644) - previews := scanFile(kt, ":feature") + previews, _ := scanFile(kt, ":feature") if len(previews) != 1 { t.Fatalf("expected 1 preview, got %d", len(previews)) } @@ -81,7 +81,7 @@ fun LargePreview() { } `), 0644) - previews := scanFile(kt, ":app") + previews, _ := scanFile(kt, ":app") if len(previews) != 1 { t.Fatalf("expected 1 preview, got %d", len(previews)) } @@ -108,7 +108,7 @@ fun DarkPreview() { } `), 0644) - previews := scanFile(kt, ":app") + previews, _ := scanFile(kt, ":app") if len(previews) != 2 { t.Fatalf("expected 2 previews, got %d", len(previews)) } @@ -130,7 +130,7 @@ fun SimplePreview() { } `), 0644) - previews := scanFile(kt, ":app") + previews, _ := scanFile(kt, ":app") if len(previews) != 1 { t.Fatalf("expected 1 preview, got %d", len(previews)) } diff --git a/internal/scanner/types.go b/internal/scanner/types.go index 69a74b4..1d00a90 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -14,9 +14,10 @@ type PreviewFunc struct { // Module represents a gradle module containing previews. type Module struct { - Name string // e.g. ":app" - Path string // absolute path to module root - Previews []PreviewFunc // previews found in this module + Name string // e.g. ":app" + Path string // absolute path to module root + Previews []PreviewFunc // previews found in this module + ComposableCount int // total @Composable functions (with and without @Preview) } // ScanResult holds the complete scan output. diff --git a/internal/ui/render.go b/internal/ui/render.go index fb8d982..ac1e450 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -88,7 +88,11 @@ func (m Model) renderModulesContent(maxLines int) panelContent { if m.moduleHasScreenshots(mod) { marker = screenshotMarkerStyle.Render(" ◉") } - b.WriteString(cursor + style.Render(mod.Name) + countStyle.Render(count) + marker + "\n") + composableInfo := "" + if mod.ComposableCount > 0 { + composableInfo = fmt.Sprintf("\n %d composables", mod.ComposableCount) + } + b.WriteString(cursor + style.Render(mod.Name) + countStyle.Render(count) + marker + composableInfo + "\n") } return panelContent{ content: b.String(), From 8f5e8ab5d7f3acc49eba150bbec7178bf3c47d0f Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Sat, 18 Apr 2026 00:49:58 -0300 Subject: [PATCH 4/6] navigate fullscreen --- internal/ui/app.go | 151 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 26 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 540c125..65eaeeb 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -888,33 +888,47 @@ func (m *Model) openScreenshotExternal() { // fullscreenPreview temporarily exits the TUI and shows the screenshot // fullscreen using the terminal's native graphics protocol. +// Supports navigating between previews with up/down arrow keys. 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" + if len(previews) == 0 { return nil } - name := previews[m.state.PreviewSel].FunctionName - return tea.Exec(&fullscreenImgCmd{pngData: entry.PNGData, name: name}, func(err error) tea.Msg { + return tea.Exec(&fullscreenImgCmd{ + previews: previews, + sel: m.state.PreviewSel, + cache: m.screenshotCache, + serial: m.deviceSerial(), + appId: m.appId, + delay: m.screenshotDelay, + }, func(err error) tea.Msg { return fullscreenDoneMsg{} }) } +// deviceSerial returns the serial of the first connected device, or empty. +func (m *Model) deviceSerial() string { + if len(m.devices) > 0 { + return m.devices[0].Serial + } + return "" +} + // fullscreenDoneMsg is sent when the fullscreen preview is dismissed. type fullscreenDoneMsg struct{} -// fullscreenImgCmd implements tea.ExecCommand to display an image fullscreen. +// fullscreenImgCmd implements tea.ExecCommand to display an image fullscreen +// with interactive navigation between previews. type fullscreenImgCmd struct { - pngData []byte - name string - stdin io.Reader - stdout io.Writer - stderr io.Writer + previews []scanner.PreviewFunc + sel int + cache *screenshot.Cache + serial string + appId string + delay time.Duration + stdin io.Reader + stdout io.Writer + stderr io.Writer } func (c *fullscreenImgCmd) SetStdin(r io.Reader) { c.stdin = r } @@ -922,30 +936,115 @@ 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 { + f, ok := c.stdin.(*os.File) + if !ok { + return nil + } + oldState, err := makeRaw(f.Fd()) + if err != nil { + return nil + } + defer restoreTerminal(f.Fd(), oldState) + proto := imgrender.DetectGraphics() + sel := c.sel + + for { + c.renderFullscreen(proto, sel) + + // Read key input + key := readKey(f) + switch key { + case "up": + if sel > 0 { + sel-- + } + case "down": + if sel < len(c.previews)-1 { + sel++ + } + default: + // Any other key exits fullscreen + return nil + } + } +} + +func (c *fullscreenImgCmd) renderFullscreen(proto imgrender.Protocol, sel int) { + w, h := imgrender.TerminalSize(c.stdout) + p := c.previews[sel] // 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()) + title := fmt.Sprintf(" %s (%d/%d) — ↑↓ navigate · any key to return", + p.FunctionName, sel+1, len(c.previews)) 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) + // Check cache + entry := c.cache.Get(p.FQN) + if entry != nil { + rendered := proto.Render(entry.PNGData, w, h-3) + fmt.Fprint(c.stdout, rendered) + return + } + + // Not cached — try to capture + if c.serial == "" { + fmt.Fprint(c.stdout, " No device connected") + return + } + + fmt.Fprint(c.stdout, " Capturing screenshot...") + + // Launch and capture + packages := adb.FindInstalledPackage(c.serial, c.appId) + for _, pkg := range packages { + if err := adb.LaunchPreview(c.serial, pkg, p.FQN, false); err == nil { + break + } + } + time.Sleep(c.delay) + data, err := adb.CaptureScreenshot(c.serial) + if err != nil { + fmt.Fprint(c.stdout, "\n Capture failed: "+err.Error()) + return + } + + c.cache.Put(p.FQN, data) + + // Clear and redraw with the image + fmt.Fprint(c.stdout, "\033[2J\033[H") + fmt.Fprintln(c.stdout, fmt.Sprintf(" %s (%d/%d) — ↑↓ navigate · any key to return", + p.FunctionName, sel+1, len(c.previews))) + fmt.Fprintln(c.stdout) + rendered := proto.Render(data, 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) +// readKey reads a single key or escape sequence from the terminal in raw mode. +// Returns "up", "down", or the raw character string. +func readKey(f *os.File) string { + buf := make([]byte, 3) + n, err := f.Read(buf) + if err != nil || n == 0 { + return "q" + } + if n == 1 { + return string(buf[0]) + } + // Escape sequences: ESC [ A (up), ESC [ B (down) + if n >= 3 && buf[0] == 0x1b && buf[1] == '[' { + switch buf[2] { + case 'A': + return "up" + case 'B': + return "down" } } - return nil + return string(buf[:n]) } // makeRaw puts the terminal into raw mode and returns the previous state. From 81cf3ed9b184f7b83bf44cf6a607c5a7401ee3dc Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Sat, 18 Apr 2026 00:52:19 -0300 Subject: [PATCH 5/6] update README for crash detection, fullscreen navigation, and new CLI flags Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 316f504..b76f67c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 | @@ -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. @@ -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. From 7c3a50563b6d1a979aa5ec8294dfa6ee4facdec4 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Sat, 18 Apr 2026 00:56:06 -0300 Subject: [PATCH 6/6] fix cross-platform build: split syscall code into platform-specific files Darwin uses TIOCGETA/TIOCSETA, Linux uses TCGETS/TCSETS, and Windows gets stub implementations. Also splits imgrender TerminalSize the same way. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/app.go | 26 +------------------ internal/ui/imgrender/imgrender.go | 23 ++--------------- internal/ui/imgrender/termsize_unix.go | 31 +++++++++++++++++++++++ internal/ui/imgrender/termsize_windows.go | 7 +++++ internal/ui/terminal_darwin.go | 31 +++++++++++++++++++++++ internal/ui/terminal_linux.go | 31 +++++++++++++++++++++++ internal/ui/terminal_windows.go | 12 +++++++++ 7 files changed, 115 insertions(+), 46 deletions(-) create mode 100644 internal/ui/imgrender/termsize_unix.go create mode 100644 internal/ui/imgrender/termsize_windows.go create mode 100644 internal/ui/terminal_darwin.go create mode 100644 internal/ui/terminal_linux.go create mode 100644 internal/ui/terminal_windows.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 65eaeeb..bd5fb9a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -8,9 +8,7 @@ import ( "strings" "path/filepath" "runtime" - "syscall" "time" - "unsafe" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -1047,29 +1045,7 @@ func readKey(f *os.File) string { return string(buf[:n]) } -// 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 { - 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 { - 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) -} +// makeRaw and restoreTerminal are in terminal_darwin.go / terminal_linux.go // captureScreenshot takes a screenshot of the current preview. func (m *Model) captureScreenshot() tea.Cmd { diff --git a/internal/ui/imgrender/imgrender.go b/internal/ui/imgrender/imgrender.go index bb7cf38..12329a8 100644 --- a/internal/ui/imgrender/imgrender.go +++ b/internal/ui/imgrender/imgrender.go @@ -9,9 +9,6 @@ import ( "image" _ "image/png" "io" - "os" - "syscall" - "unsafe" "golang.org/x/image/draw" ) @@ -55,25 +52,9 @@ func IsGraphicsProtocol() bool { // TerminalSize returns the terminal width and height from the given writer, // falling back to 80x24 if detection fails. +// Implementation is in termsize_unix.go / termsize_windows.go. 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, - uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws))) - width, height := int(ws.Col), int(ws.Row) - if width <= 0 { - width = 80 - } - if height <= 0 { - height = 24 - } - return width, height + return terminalSize(w) } // resizeImage decodes PNG data and scales it to fit within the given pixel dimensions, diff --git a/internal/ui/imgrender/termsize_unix.go b/internal/ui/imgrender/termsize_unix.go new file mode 100644 index 0000000..9b406d5 --- /dev/null +++ b/internal/ui/imgrender/termsize_unix.go @@ -0,0 +1,31 @@ +//go:build !windows + +package imgrender + +import ( + "io" + "os" + "syscall" + "unsafe" +) + +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, + uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws))) + width, height := int(ws.Col), int(ws.Row) + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + return width, height +} diff --git a/internal/ui/imgrender/termsize_windows.go b/internal/ui/imgrender/termsize_windows.go new file mode 100644 index 0000000..b75c5db --- /dev/null +++ b/internal/ui/imgrender/termsize_windows.go @@ -0,0 +1,7 @@ +package imgrender + +import "io" + +func terminalSize(w io.Writer) (int, int) { + return 80, 24 +} diff --git a/internal/ui/terminal_darwin.go b/internal/ui/terminal_darwin.go new file mode 100644 index 0000000..055bbb1 --- /dev/null +++ b/internal/ui/terminal_darwin.go @@ -0,0 +1,31 @@ +package ui + +import ( + "syscall" + "unsafe" +) + +// termState holds the terminal state for save/restore. +type termState = syscall.Termios + +func makeRaw(fd uintptr) (termState, error) { + var oldState termState + if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd, + uintptr(syscall.TIOCGETA), uintptr(unsafe.Pointer(&oldState)), 0, 0, 0); err != 0 { + 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 { + return oldState, err + } + return oldState, nil +} + +func restoreTerminal(fd uintptr, state termState) { + syscall.Syscall6(syscall.SYS_IOCTL, fd, + uintptr(syscall.TIOCSETA), uintptr(unsafe.Pointer(&state)), 0, 0, 0) +} diff --git a/internal/ui/terminal_linux.go b/internal/ui/terminal_linux.go new file mode 100644 index 0000000..053afde --- /dev/null +++ b/internal/ui/terminal_linux.go @@ -0,0 +1,31 @@ +package ui + +import ( + "syscall" + "unsafe" +) + +// termState holds the terminal state for save/restore. +type termState = syscall.Termios + +func makeRaw(fd uintptr) (termState, error) { + var oldState termState + if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, fd, + uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&oldState)), 0, 0, 0); err != 0 { + 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.TCSETS), uintptr(unsafe.Pointer(&newState)), 0, 0, 0); err != 0 { + return oldState, err + } + return oldState, nil +} + +func restoreTerminal(fd uintptr, state termState) { + syscall.Syscall6(syscall.SYS_IOCTL, fd, + uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(&state)), 0, 0, 0) +} diff --git a/internal/ui/terminal_windows.go b/internal/ui/terminal_windows.go new file mode 100644 index 0000000..5d82f96 --- /dev/null +++ b/internal/ui/terminal_windows.go @@ -0,0 +1,12 @@ +package ui + +import "fmt" + +// termState holds the terminal state for save/restore. +type termState struct{} + +func makeRaw(fd uintptr) (termState, error) { + return termState{}, fmt.Errorf("raw mode not supported on windows") +} + +func restoreTerminal(fd uintptr, state termState) {}