package peco import ( "context" "fmt" "io" "os" "runtime/debug" "sync" "github.com/gdamore/tcell/v2" pdebug "github.com/lestrrat-go/pdebug" "github.com/peco/peco/config" ) // InlineScreen implements the Screen interface for rendering peco in a // portion of the terminal without using the alternate screen buffer. // This preserves terminal scroll history above the inline region. type InlineScreen struct { mutex sync.Mutex screen tcell.Screen heightSpec config.HeightSpec height int // resolved line count yOffset int // physical row where inline region starts // savedAltscreen holds the original TCELL_ALTSCREEN value so we can // restore it on Close. savedAltscreen string errWriter io.Writer // destination for error output (defaults to os.Stderr) } // NewInlineScreen creates a new InlineScreen with the given height spec. func NewInlineScreen(spec config.HeightSpec) *InlineScreen { return &InlineScreen{ heightSpec: spec, errWriter: os.Stderr, } } // Init initializes the tcell screen for inline mode, disabling the alternate screen buffer. func (s *InlineScreen) Init(_ *config.Config) error { // Save and override TCELL_ALTSCREEN to prevent alternate screen buffer s.savedAltscreen = os.Getenv("TCELL_ALTSCREEN") os.Setenv("TCELL_ALTSCREEN", "disable") screen, err := tcell.NewScreen() if err != nil { os.Setenv("TCELL_ALTSCREEN", s.savedAltscreen) return fmt.Errorf("failed to create tcell screen: %w", err) } if err := screen.Init(); err != nil { os.Setenv("TCELL_ALTSCREEN", s.savedAltscreen) return fmt.Errorf("failed to initialize tcell screen: %w", err) } screen.EnableMouse() s.mutex.Lock() s.screen = screen s.mutex.Unlock() termWidth, termHeight := screen.Size() s.height = s.heightSpec.Resolve(termHeight) s.yOffset = termHeight - s.height // Push existing terminal content up by writing newlines to the TTY, // then move cursor back up to the start of our region. if tty, ok := screen.Tty(); ok { buf := make([]byte, s.height) for i := range buf { buf[i] = '\n' } _, _ = tty.Write(buf) // Move cursor up to the start of our inline region fmt.Fprintf(tty, "\033[%dA", s.height) } // Lock the region above our area so tcell won't overwrite it screen.LockRegion(0, 0, termWidth, s.yOffset, true) // Clear our region for y := range s.height { for x := range termWidth { screen.SetContent(x, s.yOffset+y, ' ', nil, tcell.StyleDefault) } } screen.Show() return nil } func (s *InlineScreen) Close() error { if pdebug.Enabled { pdebug.Printf("InlineScreen: Close") } s.mutex.Lock() scr := s.screen s.screen = nil s.mutex.Unlock() if scr != nil { // Clear our region and position cursor at the start of it if tty, ok := scr.Tty(); ok { // Move cursor to the start of our inline region and clear from there down fmt.Fprintf(tty, "\033[%d;1H", s.yOffset+1) // 1-based row _, _ = tty.Write([]byte("\033[J")) // clear from cursor to end of screen } scr.Fini() } // Restore original TCELL_ALTSCREEN if s.savedAltscreen == "" { os.Unsetenv("TCELL_ALTSCREEN") } else { os.Setenv("TCELL_ALTSCREEN", s.savedAltscreen) } return nil } func (s *InlineScreen) SetCell(x, y int, ch rune, fg, bg config.Attribute) { s.mutex.Lock() defer s.mutex.Unlock() if s.screen == nil { return } style := attributeToTcellStyle(fg, bg) s.screen.SetContent(x, y+s.yOffset, ch, nil, style) } func (s *InlineScreen) SetCursor(x, y int) { s.mutex.Lock() defer s.mutex.Unlock() if s.screen == nil { return } s.screen.ShowCursor(x, y+s.yOffset) } func (s *InlineScreen) Print(args PrintArgs) int { return screenPrint(s, args) } func (s *InlineScreen) Flush() error { s.mutex.Lock() defer s.mutex.Unlock() if s.screen == nil { return nil } s.screen.Show() return nil } func (s *InlineScreen) Sync() { s.mutex.Lock() defer s.mutex.Unlock() if s.screen == nil { return } s.screen.Sync() } // Size returns the constrained dimensions (full width, inline height). func (s *InlineScreen) Size() (int, int) { s.mutex.Lock() defer s.mutex.Unlock() if s.screen == nil { return 0, 0 } w, _ := s.screen.Size() return w, s.height } // PollEvent creates an event channel and polls for terminal events with special resize handling. func (s *InlineScreen) PollEvent(ctx context.Context, _ *config.Config) chan Event { evCh := make(chan Event) go func() { defer func() { if r := recover(); r != nil { fmt.Fprintf(s.errWriter, "peco: panic in PollEvent goroutine: %v\n%s", r, debug.Stack()) } close(evCh) }() for { s.mutex.Lock() scr := s.screen s.mutex.Unlock() if scr == nil { return } ev := scr.PollEvent() if ev == nil { return } // On resize, recalculate height and yOffset if _, ok := ev.(*tcell.EventResize); ok { s.mutex.Lock() if s.screen != nil { termWidth, termHeight := s.screen.Size() s.height = s.heightSpec.Resolve(termHeight) s.yOffset = termHeight - s.height // Re-lock the region above s.screen.LockRegion(0, 0, termWidth, s.yOffset, true) } s.mutex.Unlock() } pecoEv := tcellEventToEvent(ev) select { case <-ctx.Done(): return case evCh <- pecoEv: } } }() return evCh } // SendEvent is a no-op for InlineScreen (same as TcellScreen). func (s *InlineScreen) SendEvent(_ Event) {} // Suspend is a no-op for inline mode. func (s *InlineScreen) Suspend() {} // Resume is a no-op for inline mode. func (s *InlineScreen) Resume(_ context.Context) error { return nil }