diff --git a/README.md b/README.md index 7e13fd2..316f504 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 · 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 @@ -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 @@ -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 └──────────────────────────────────────────────────────────────────────────────┘ ``` @@ -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 | @@ -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. diff --git a/cmd/compose-preview/main.go b/cmd/compose-preview/main.go index 669d8c0..cdb3ce0 100644 --- a/cmd/compose-preview/main.go +++ b/cmd/compose-preview/main.go @@ -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. @@ -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) { diff --git a/internal/ui/app.go b/internal/ui/app.go index 3cee3c7..1bf1867 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2,11 +2,14 @@ package ui import ( "fmt" + "io" "os" "os/exec" "path/filepath" "runtime" + "syscall" "time" + "unsafe" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -267,6 +270,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -498,6 +504,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -834,6 +845,92 @@ func (m *Model) openScreenshotExternal() { 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 { + 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) +} + // captureScreenshot takes a screenshot of the current preview. func (m *Model) captureScreenshot() tea.Cmd { previews := m.filteredPreviews() diff --git a/internal/ui/imgrender/detect.go b/internal/ui/imgrender/detect.go index 8c33f7c..0bca1e3 100644 --- a/internal/ui/imgrender/detect.go +++ b/internal/ui/imgrender/detect.go @@ -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{} } diff --git a/internal/ui/imgrender/imgrender.go b/internal/ui/imgrender/imgrender.go index 3da3f19..bb7cf38 100644 --- a/internal/ui/imgrender/imgrender.go +++ b/internal/ui/imgrender/imgrender.go @@ -8,6 +8,10 @@ import ( "bytes" "image" _ "image/png" + "io" + "os" + "syscall" + "unsafe" "golang.org/x/image/draw" ) @@ -36,6 +40,42 @@ func ProtocolName() string { 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, + 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 +} + // 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) { diff --git a/internal/ui/render.go b/internal/ui/render.go index 0b6ea0f..fb8d982 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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, " · ")) }