From 8e620f320181b6fbc367e166de226bbf5840a785 Mon Sep 17 00:00:00 2001 From: Ignacio Tomas Crespo Date: Sun, 26 Apr 2026 12:33:38 -0300 Subject: [PATCH] emulator fast mode --- README.md | 24 +++++++++-- internal/adb/adb.go | 66 +++++++++++++++++++++++++++++- internal/ui/app.go | 94 ++++++++++++++++++++++++++++++++++++++++++- internal/ui/render.go | 2 +- internal/ui/view.go | 60 ++++++++++++++++++++++++++- 5 files changed, 236 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b76f67c..dd95384 100644 --- a/README.md +++ b/README.md @@ -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 · f fullscreen · w web · i install · / filter · d device · q quit + enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · x kill emu · q quit ``` ## What it does @@ -49,7 +49,8 @@ You want to check a Compose preview. You open Android Studio. You wait 2 minutes - **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 +- **Device / Emulator picker** — Press `d` to select a connected device or launch an AVD emulator, with optional fast mode (headless + Quick Boot) +- **Kill emulator** — Press `x` to list running emulators and kill one (useful for headless emulators) - **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 @@ -130,7 +131,7 @@ compose-preview --dismiss-dialog ├──────────────────────────────────────────────────────────────────────────────┤ │ ⚠ sources changed since last build — press 'i' to install │ status ├──────────────────────────────────────────────────────────────────────────────┤ -│ enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · q quit │ help +│ enter run · s screenshot · f fullscreen · w web · i install · / filter · d device · x kill emu · q quit │ help └──────────────────────────────────────────────────────────────────────────────┘ ``` @@ -147,7 +148,8 @@ compose-preview --dismiss-dialog | `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 | +| `d` | Open device / emulator picker (press `f` in picker to toggle fast mode) | +| `x` | Open kill emulator picker — select a running emulator to stop it | | `R` | Refresh project scan | | `1` / `2` | Focus Modules / Previews panel directly | | `q` | Quit | @@ -194,6 +196,20 @@ The terminal screenshot is low resolution. For full-quality rendering, press `w` Press `d` to open a modal listing connected devices and available AVD emulators. Select a device to target, or pick an emulator to launch it. +Press `f` inside the picker to toggle **fast mode** — launches the emulator headless (no window, no audio) with Quick Boot snapshots and host GPU acceleration. This is significantly faster than a cold boot and ideal for CI or when you don't need the emulator UI. + +### Kill emulator + +Press `x` to open a modal listing all running emulators. Select one and press `Enter` to kill it. This is especially useful for headless emulators launched in fast mode, which have no window to close. + +``` +╭ Kill Emulator ───────────────────────╮ +│ ▸ Pixel_6_API_34 (emulator-5554) │ +│ Pixel_7_API_35 (emulator-5556) │ +│ ↑↓ navigate · enter kill · esc cancel│ +╰──────────────────────────────────────╯ +``` + ### Install variants When you press `i`, compose-preview queries Gradle for all available install tasks. If your project has multiple build variants (dev, qa, accept, production), a picker modal appears: diff --git a/internal/adb/adb.go b/internal/adb/adb.go index 1a5e902..6274070 100644 --- a/internal/adb/adb.go +++ b/internal/adb/adb.go @@ -101,12 +101,20 @@ func ListAVDs() []AVD { // StartEmulator launches an emulator AVD in the background. // Returns immediately — the emulator boots asynchronously. -func StartEmulator(avdName string) error { +// If fastMode is true, runs headless with Quick Boot and host GPU for faster startup. +func StartEmulator(avdName string, fastMode bool) error { emulator, err := findEmulator() if err != nil { return err } - cmd := exec.Command(emulator, "-avd", avdName, "-no-snapshot-load") + args := []string{"-avd", avdName} + if fastMode { + // Headless: no window, no audio, use host GPU acceleration, Quick Boot (snapshot) enabled + args = append(args, "-no-window", "-no-audio", "-gpu", "auto") + } else { + args = append(args, "-no-snapshot-load") + } + cmd := exec.Command(emulator, args...) cmd.Stdout = nil cmd.Stderr = nil return cmd.Start() @@ -131,6 +139,60 @@ func findEmulator() (string, error) { return "", fmt.Errorf("emulator not found — set ANDROID_HOME or add emulator to PATH") } +// RunningEmulator represents a currently running emulator instance. +type RunningEmulator struct { + Serial string // e.g. "emulator-5554" + AVD string // AVD name if available +} + +// ListRunningEmulators returns emulator devices that are currently running. +func ListRunningEmulators() ([]RunningEmulator, error) { + devices, err := DetectDevices() + if err != nil { + return nil, err + } + var emulators []RunningEmulator + for _, d := range devices { + if !strings.HasPrefix(d.Serial, "emulator-") { + continue + } + if d.State != "device" { + continue + } + avdName := getEmulatorAVDName(d.Serial) + emulators = append(emulators, RunningEmulator{ + Serial: d.Serial, + AVD: avdName, + }) + } + return emulators, nil +} + +// getEmulatorAVDName queries the emulator for its AVD name via adb. +func getEmulatorAVDName(serial string) string { + out, err := exec.Command("adb", "-s", serial, "emu", "avd", "name").Output() + if err != nil { + return "" + } + // First line is the AVD name + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line != "" && line != "OK" { + return line + } + } + return "" +} + +// KillEmulator kills a running emulator by serial. +func KillEmulator(serial string) error { + out, err := exec.Command("adb", "-s", serial, "emu", "kill").CombinedOutput() + if err != nil { + return fmt.Errorf("kill emulator %s: %s", serial, strings.TrimSpace(string(out))) + } + return nil +} + // WaitForDevice waits for a device to come online (up to timeout). func WaitForDevice(timeout int) error { args := []string{"wait-for-device"} diff --git a/internal/ui/app.go b/internal/ui/app.go index bd5fb9a..07b1413 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -55,6 +55,12 @@ type emulatorReadyMsg struct { device adb.Device } +// emulatorKilledMsg is sent when an emulator has been killed. +type emulatorKilledMsg struct { + serial string + err error +} + // installTasksMsg is sent when gradle install task discovery completes. type installTasksMsg struct { tasks []string @@ -101,6 +107,10 @@ type Model struct { devicePickerSel int // cursor in the picker devicePickerItems []devicePickerItem // combined list of devices + AVDs emulatorBooting bool + emulatorFastMode bool // headless + Quick Boot + showKillPicker bool // kill emulator modal + killPickerSel int // cursor in the kill picker + killPickerItems []adb.RunningEmulator // running emulators // Build status statusMsg string @@ -222,6 +232,7 @@ func NewModel(result scanner.ScanResult, projectRoot string, opts ...Options) Mo searchInput: si, avds: avds, showDevicePicker: showPicker, + emulatorFastMode: true, panelRegions: make(map[types.PanelID]panel.Region), } if showPicker { @@ -329,6 +340,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.errorMsg = "" return m, nil + case emulatorKilledMsg: + if msg.err != nil { + m.errorMsg = fmt.Sprintf("Failed to kill emulator: %v", msg.err) + } else { + m.statusMsg = fmt.Sprintf("Emulator %s killed", msg.serial) + // Remove killed device from list + var remaining []adb.Device + for _, d := range m.devices { + if d.Serial != msg.serial { + remaining = append(remaining, d) + } + } + m.devices = remaining + if len(m.devices) > 0 { + m.deviceStatus = m.devices[0].Model + if m.deviceStatus == "" { + m.deviceStatus = m.devices[0].Serial + } + } else { + m.deviceStatus = "no device" + } + } + return m, nil + case adbLaunchMsg: if msg.err != nil { m.errorMsg = fmt.Sprintf("Launch failed: %v", msg.err) @@ -356,6 +391,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Kill emulator picker modal is active + if m.showKillPicker { + switch msg.String() { + case "up", "k": + if m.killPickerSel > 0 { + m.killPickerSel-- + } + return m, nil + case "down", "j": + if m.killPickerSel < len(m.killPickerItems)-1 { + m.killPickerSel++ + } + return m, nil + case "enter": + if m.killPickerSel < len(m.killPickerItems) { + emu := m.killPickerItems[m.killPickerSel] + m.showKillPicker = false + serial := emu.Serial + m.statusMsg = fmt.Sprintf("Killing emulator %s...", serial) + return m, func() tea.Msg { + err := adb.KillEmulator(serial) + return emulatorKilledMsg{serial: serial, err: err} + } + } + return m, nil + case "esc", "q": + m.showKillPicker = false + return m, nil + case "ctrl+c": + return m, tea.Quit + } + return m, nil + } + // Device picker modal is active if m.showDevicePicker { switch msg.String() { @@ -392,6 +461,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil + case "f": + m.emulatorFastMode = !m.emulatorFastMode + return m, nil case "esc", "q": m.showDevicePicker = false return m, nil @@ -479,6 +551,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // "x" opens kill emulator picker + if key == "x" { + emulators, err := adb.ListRunningEmulators() + if err != nil || len(emulators) == 0 { + m.errorMsg = "No running emulators found" + return m, nil + } + m.killPickerItems = emulators + m.killPickerSel = 0 + m.showKillPicker = true + return m, nil + } + // "w" toggles web preview viewer if key == "w" { if m.webServer == nil { @@ -1156,10 +1241,15 @@ func (m *Model) buildPickerItems() []devicePickerItem { // launchEmulator starts an emulator AVD asynchronously. func (m *Model) launchEmulator(avdName string) tea.Cmd { - m.statusMsg = fmt.Sprintf("Launching emulator %s...", avdName) + fastMode := m.emulatorFastMode + mode := "" + if fastMode { + mode = " (fast/headless)" + } + m.statusMsg = fmt.Sprintf("Launching emulator %s%s...", avdName, mode) m.errorMsg = "" return func() tea.Msg { - err := adb.StartEmulator(avdName) + err := adb.StartEmulator(avdName, fastMode) return emulatorStartedMsg{avdName: avdName, err: err} } } diff --git a/internal/ui/render.go b/internal/ui/render.go index ac1e450..7365fd8 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -197,7 +197,7 @@ func (m Model) renderHelp() string { if m.webServer != nil { webLabel = "w stop web" } - parts := []string{"enter run", "s screenshot", "f fullscreen HD", webLabel, "i install", "/ filter", "d device", "q quit"} + parts := []string{"enter run", "s screenshot", "f fullscreen HD", webLabel, "i install", "/ filter", "d device", "x kill emu", "q quit"} return helpStyle.Render(" " + strings.Join(parts, " · ")) } diff --git a/internal/ui/view.go b/internal/ui/view.go index ae8e1ef..eff293b 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -152,6 +152,12 @@ func (m Model) View() string { layout = m.overlayModal(layout, modal) } + // Overlay kill emulator picker modal if active + if m.showKillPicker { + modal := m.renderKillPickerModal() + layout = m.overlayModal(layout, modal) + } + return layout } @@ -187,6 +193,17 @@ func (m Model) renderDevicePickerModal() string { lines = append(lines, statusBarStyle.Render(" No devices or emulators found")) } + // Show fast mode toggle if there are AVDs + if hasAVDs { + lines = append(lines, "") + fastStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + fastLabel := "[ ] Fast mode (headless, Quick Boot)" + if m.emulatorFastMode { + fastLabel = "[✓] Fast mode (headless, Quick Boot)" + } + lines = append(lines, fastStyle.Render(" "+fastLabel)) + } + content := "" for _, l := range lines { content += l + "\n" @@ -204,7 +221,7 @@ func (m Model) renderDevicePickerModal() string { } modalH := len(lines) - help := helpStyle.Render(" ↑↓ navigate · enter select · esc cancel") + help := helpStyle.Render(" ↑↓ navigate · enter select · f fast mode · esc cancel") return panel.Box(0, "Select Device / Emulator", content+help, modalW, modalH+1, true, panel.BoxOpts{Accent: selectedAccent}) @@ -251,6 +268,47 @@ func (m Model) renderInstallPickerModal() string { panel.BoxOpts{Accent: selectedAccent}) } +// renderKillPickerModal renders the kill emulator selection modal. +func (m Model) renderKillPickerModal() string { + var lines []string + + for i, emu := range m.killPickerItems { + cursor := " " + style := normalItemStyle + if i == m.killPickerSel { + cursor = "▸ " + style = selectedItemStyle + } + label := emu.Serial + if emu.AVD != "" { + label = fmt.Sprintf("%s (%s)", emu.AVD, emu.Serial) + } + lines = append(lines, cursor+style.Render(label)) + } + + content := "" + for _, l := range lines { + content += l + "\n" + } + + modalW := 40 + for _, emu := range m.killPickerItems { + w := len(emu.Serial) + len(emu.AVD) + 7 + if w > modalW { + modalW = w + } + } + if modalW > m.width-4 { + modalW = m.width - 4 + } + + modalH := len(lines) + help := helpStyle.Render(" ↑↓ navigate · enter kill · esc cancel") + + return panel.Box(0, "Kill Emulator", content+help, modalW, modalH+1, true, + panel.BoxOpts{Accent: selectedAccent}) +} + // overlayModal places a modal string centered on top of the base layout. func (m Model) overlayModal(base, modal string) string { baseLines := strings.Split(base, "\n")