peco.peco/screen.go
Masayoshi Wada 42c39e38c8 fix: Ctrl+Space (ToggleSelection) not working after tcell migration
tcellEventToEvent did not convert Ctrl+Space to KeyCtrlSpace for
CSI u terminals or traditional terminals (tcell emits KeyCtrlSpace
which fell outside the KeyCtrlA..KeyCtrlZ range).
2026-03-01 20:42:04 +09:00

568 lines
13 KiB
Go

package peco
import (
"context"
"fmt"
"io"
"os"
"runtime/debug"
"sync"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
pdebug "github.com/lestrrat-go/pdebug"
"github.com/mattn/go-runewidth"
"github.com/peco/peco/config"
"github.com/peco/peco/internal/ansi"
"github.com/peco/peco/internal/keyseq"
)
// Screen hides the terminal library from the consuming code so that
// it can be swapped out for testing
type Screen interface {
Init(*config.Config) error
Close() error
Flush() error
PollEvent(context.Context, *config.Config) chan Event
Print(PrintArgs) int
Resume(context.Context) error
SetCell(int, int, rune, config.Attribute, config.Attribute)
SetCursor(int, int)
Size() (int, int)
SendEvent(Event)
Suspend()
}
// TcellScreen implements the Screen interface using tcell/v2.
type TcellScreen struct {
mutex sync.Mutex
screen tcell.Screen
resumeCh chan chan error
suspendCh chan struct{}
doneCh chan struct{} // closed on permanent Close() to signal goroutines to exit
closeOnce sync.Once // ensures doneCh is closed exactly once
errWriter io.Writer // destination for error output (defaults to os.Stderr)
}
// tcellKeyToKeyseq maps tcell navigation/function key constants to peco keyseq constants.
var tcellKeyToKeyseq = map[tcell.Key]keyseq.KeyType{
tcell.KeyUp: keyseq.KeyArrowUp,
tcell.KeyDown: keyseq.KeyArrowDown,
tcell.KeyLeft: keyseq.KeyArrowLeft,
tcell.KeyRight: keyseq.KeyArrowRight,
tcell.KeyInsert: keyseq.KeyInsert,
tcell.KeyDelete: keyseq.KeyDelete,
tcell.KeyHome: keyseq.KeyHome,
tcell.KeyEnd: keyseq.KeyEnd,
tcell.KeyPgUp: keyseq.KeyPgup,
tcell.KeyPgDn: keyseq.KeyPgdn,
tcell.KeyF1: keyseq.KeyF1,
tcell.KeyF2: keyseq.KeyF2,
tcell.KeyF3: keyseq.KeyF3,
tcell.KeyF4: keyseq.KeyF4,
tcell.KeyF5: keyseq.KeyF5,
tcell.KeyF6: keyseq.KeyF6,
tcell.KeyF7: keyseq.KeyF7,
tcell.KeyF8: keyseq.KeyF8,
tcell.KeyF9: keyseq.KeyF9,
tcell.KeyF10: keyseq.KeyF10,
tcell.KeyF11: keyseq.KeyF11,
tcell.KeyF12: keyseq.KeyF12,
tcell.KeyBackspace: keyseq.KeyBackspace,
tcell.KeyTab: keyseq.KeyTab,
tcell.KeyEnter: keyseq.KeyEnter,
tcell.KeyEscape: keyseq.KeyEsc,
tcell.KeyBacktab: keyseq.KeyTab, // Shift+Tab → Tab for compatibility
}
// tcellEventToEvent converts a tcell.Event to peco's internal Event type.
func tcellEventToEvent(tev tcell.Event) Event {
switch ev := tev.(type) {
case *tcell.EventKey:
var mod keyseq.ModifierKey
if ev.Modifiers()&tcell.ModCtrl != 0 {
mod |= keyseq.ModCtrl
}
if ev.Modifiers()&tcell.ModShift != 0 {
mod |= keyseq.ModShift
}
if ev.Modifiers()&tcell.ModAlt != 0 {
mod |= keyseq.ModAlt
}
key := ev.Key()
// Rune keys (printable characters)
if key == tcell.KeyRune {
r := ev.Rune()
// Ctrl+letter still as rune: this happens when additional
// modifiers (Alt, Shift) are combined with Ctrl, which
// prevents tcell's NewEventKey from normalizing to KeyCtrl*.
// Convert to the control code that peco's keyseq expects.
if mod&keyseq.ModCtrl != 0 && r >= 'a' && r <= 'z' {
mod &^= keyseq.ModCtrl
return Event{
Type: EventKey,
Key: keyseq.KeyType(r - 'a' + 1),
Ch: 0,
Mod: mod,
}
}
// Ctrl+Space via CSI u: tcell delivers KeyRune with
// rune=' ' and ModCtrl. Convert to KeyCtrlSpace (0x00).
if r == ' ' && mod&keyseq.ModCtrl != 0 {
mod &^= keyseq.ModCtrl
return Event{
Type: EventKey,
Key: keyseq.KeyCtrlSpace,
Ch: 0,
Mod: mod,
}
}
// Plain space must be sent as KeySpace with Ch=0
// to match the convention expected by doAcceptChar.
if r == ' ' {
return Event{
Type: EventKey,
Key: keyseq.KeySpace,
Ch: 0,
Mod: mod,
}
}
return Event{
Type: EventKey,
Key: 0,
Ch: r,
Mod: mod,
}
}
// Navigation/function keys via lookup table
if mapped, ok := tcellKeyToKeyseq[key]; ok {
return Event{
Type: EventKey,
Key: mapped,
Ch: 0,
Mod: mod,
}
}
// Ctrl keys: tcell.KeyCtrlSpace(64)..KeyCtrlZ(90).
// On terminals with enhanced keyboard protocols (CSI u /
// fixterms), tcell normalizes Ctrl+letter to these constants
// with ModCtrl set. Peco's keyseq system encodes the ctrl
// nature in the key value (0x00-0x1A), not in the modifier,
// so strip the redundant ModCtrl. (issue #715)
if key >= tcell.KeyCtrlSpace && key <= tcell.KeyCtrlZ {
mod &^= keyseq.ModCtrl
return Event{
Type: EventKey,
Key: keyseq.KeyType(key - tcell.KeyCtrlSpace),
Ch: 0,
Mod: mod,
}
}
// Raw control codes (0x00-0x1F) and DEL (0x7F): these arrive
// from traditional terminals. tcell may add ModCtrl during
// normalization; strip it since peco's key bindings register
// ctrl keys with Modifier=0.
if key <= 0x1F || key == 0x7F {
mod &^= keyseq.ModCtrl
return Event{
Type: EventKey,
Key: keyseq.KeyType(key),
Ch: 0,
Mod: mod,
}
}
// Fallback: treat as error
return Event{Type: EventError}
case *tcell.EventMouse:
buttons := ev.Buttons()
var key keyseq.KeyType
switch {
case buttons&tcell.Button1 != 0:
key = keyseq.MouseLeft
case buttons&tcell.Button2 != 0:
key = keyseq.MouseMiddle
case buttons&tcell.Button3 != 0:
key = keyseq.MouseRight
default:
return Event{Type: EventError}
}
var mod keyseq.ModifierKey
if ev.Modifiers()&tcell.ModCtrl != 0 {
mod |= keyseq.ModCtrl
}
if ev.Modifiers()&tcell.ModShift != 0 {
mod |= keyseq.ModShift
}
if ev.Modifiers()&tcell.ModAlt != 0 {
mod |= keyseq.ModAlt
}
return Event{
Type: EventKey,
Key: key,
Ch: 0,
Mod: mod,
}
case *tcell.EventResize:
return Event{Type: EventResize}
default:
return Event{Type: EventError}
}
}
// attributeToTcellColor converts a peco Attribute to a tcell.Color.
func attributeToTcellColor(attr config.Attribute) tcell.Color {
if attr&config.AttrTrueColor != 0 {
rgb := attr & 0x00FFFFFF
return tcell.NewHexColor(int32(rgb))
}
colorVal := attr & 0x01FF
if colorVal == 0 {
return tcell.ColorDefault
}
return tcell.PaletteColor(int(colorVal - 1))
}
// attributeToTcellStyle converts peco Attribute fg/bg values to a tcell.Style.
func attributeToTcellStyle(fg, bg config.Attribute) tcell.Style {
style := tcell.StyleDefault.
Foreground(attributeToTcellColor(fg)).
Background(attributeToTcellColor(bg))
// Extract style attributes from both fg and bg
attrs := fg | bg
if attrs&config.AttrBold != 0 {
style = style.Bold(true)
}
if attrs&config.AttrUnderline != 0 {
style = style.Underline(true)
}
if attrs&config.AttrReverse != 0 {
style = style.Reverse(true)
}
return style
}
func (t *TcellScreen) Init(_ *config.Config) error {
screen, err := tcell.NewScreen()
if err != nil {
return fmt.Errorf("failed to create tcell screen: %w", err)
}
if err := screen.Init(); err != nil {
return fmt.Errorf("failed to initialize tcell screen: %w", err)
}
screen.EnableMouse()
t.screen = screen
return nil
}
// NewTcellScreen creates a new TcellScreen with initialized channels and default error output.
func NewTcellScreen() *TcellScreen {
return &TcellScreen{
suspendCh: make(chan struct{}),
resumeCh: make(chan chan error),
doneCh: make(chan struct{}),
errWriter: os.Stderr,
}
}
// finiScreen finalizes the tcell screen without signaling a permanent
// shutdown. Used by the suspend handler so the goroutine continues
// to listen for further suspend/resume cycles.
func (t *TcellScreen) finiScreen() {
t.mutex.Lock()
s := t.screen
t.screen = nil
t.mutex.Unlock()
if s != nil {
s.Fini()
}
}
// Close permanently shuts down the screen and signals all goroutines
// started by PollEvent to exit.
func (t *TcellScreen) Close() error {
if pdebug.Enabled {
pdebug.Printf("TcellScreen: Close")
}
t.finiScreen()
t.closeOnce.Do(func() { close(t.doneCh) })
return nil
}
func (t *TcellScreen) SetCursor(x, y int) {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.screen == nil {
return
}
t.screen.ShowCursor(x, y)
}
// SendEvent is used to allow programmers generate random
// events, but it's only useful for testing purposes.
// When interacting with tcell, this method is a noop
func (t *TcellScreen) SendEvent(_ Event) {
// no op
}
// Flush calls tcell's Show to synchronize the screen
func (t *TcellScreen) Flush() error {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.screen == nil {
return nil
}
t.screen.Show()
return nil
}
// Sync forces a complete redraw of every cell on the physical display.
// This recovers from screen corruption caused by external output (e.g.,
// STDERR messages written directly to the terminal).
func (t *TcellScreen) Sync() {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.screen == nil {
return
}
t.screen.Sync()
}
// PollEvent returns a channel that you can listen to for
// terminal events. The actual polling is done in a
// separate goroutine
func (t *TcellScreen) PollEvent(ctx context.Context, cfg *config.Config) chan Event {
evCh := make(chan Event)
go func() {
// keep listening to suspend requests here
for {
select {
case <-ctx.Done():
return
case <-t.doneCh:
return
case <-t.suspendCh:
if pdebug.Enabled {
pdebug.Printf("poll event suspended!")
}
t.finiScreen()
}
}
}()
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(t.errWriter, "peco: panic in PollEvent goroutine: %v\n%s", r, debug.Stack())
}
close(evCh)
}()
for {
t.mutex.Lock()
s := t.screen
t.mutex.Unlock()
if s == nil {
// Screen finalized, treat as suspend/interrupt
select {
case <-ctx.Done():
return
case <-t.doneCh:
return
case replyCh := <-t.resumeCh:
if err := t.Init(cfg); err != nil {
fmt.Fprintf(t.errWriter, "peco: failed to re-initialize screen on resume: %v\n", err)
replyCh <- err
} else {
replyCh <- nil
}
continue
}
}
ev := s.PollEvent()
if ev == nil {
// PollEvent returns nil when screen is finalized.
// Wait for resume or context cancellation.
select {
case <-ctx.Done():
return
case <-t.doneCh:
return
case replyCh := <-t.resumeCh:
if err := t.Init(cfg); err != nil {
fmt.Fprintf(t.errWriter, "peco: failed to re-initialize screen on resume: %v\n", err)
replyCh <- err
} else {
replyCh <- nil
}
}
continue
}
evCh <- tcellEventToEvent(ev)
}
}()
return evCh
}
// Suspend signals the event polling goroutine to suspend the screen.
func (t *TcellScreen) Suspend() {
select {
case t.suspendCh <- struct{}{}:
default:
}
}
// Resume sends a resume request and waits for screen re-initialization to complete.
func (t *TcellScreen) Resume(ctx context.Context) error {
// Resume must be a block operation, because we can't safely proceed
// without actually knowing that the screen has been re-initialized.
// So we send a channel where we expect a reply back, and wait for that.
//
// Both selects are guarded by ctx.Done() to avoid deadlock: if the
// polling goroutine is not yet waiting on resumeCh, a non-blocking
// send would silently drop the message and the subsequent receive
// would block forever.
ch := make(chan error, 1)
select {
case t.resumeCh <- ch:
case <-ctx.Done():
return ctx.Err()
}
select {
case err := <-ch:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// SetCell writes to the terminal
func (t *TcellScreen) SetCell(x, y int, ch rune, fg, bg config.Attribute) {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.screen == nil {
return
}
style := attributeToTcellStyle(fg, bg)
t.screen.SetContent(x, y, ch, nil, style)
}
// Size returns the dimensions of the current terminal
func (t *TcellScreen) Size() (int, int) {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.screen == nil {
return 0, 0
}
return t.screen.Size()
}
type PrintArgs struct {
X int
XOffset int
Y int
Fg config.Attribute
Bg config.Attribute
Msg string
Fill bool
ANSIAttrs []ansi.AttrSpan // per-character ANSI attributes for this segment
}
func (t *TcellScreen) Print(args PrintArgs) int {
return screenPrint(t, args)
}
// screenPrint writes a string to the screen with tab expansion, ANSI color support, and optional line fill.
func screenPrint(t Screen, args PrintArgs) int {
var written int
bg := args.Bg
fg := args.Fg
msg := args.Msg
x := args.X
y := args.Y
xOffset := args.XOffset
// ANSI span tracking
ansiAttrs := args.ANSIAttrs
spanIdx := 0
spanPos := 0
for len(msg) > 0 {
c, w := utf8.DecodeRuneInString(msg)
if c == utf8.RuneError {
c = '?'
w = 1
}
msg = msg[w:]
// Determine effective fg/bg for this character
efg, ebg := fg, bg
if ansiAttrs != nil && spanIdx < len(ansiAttrs) {
span := ansiAttrs[spanIdx]
if config.Attribute(span.Fg) != config.ColorDefault {
efg = config.Attribute(span.Fg)
}
if config.Attribute(span.Bg) != config.ColorDefault {
ebg = config.Attribute(span.Bg)
}
spanPos++
if spanPos >= span.Length {
spanIdx++
spanPos = 0
}
}
if c == '\t' {
// In case we found a tab, we draw it as spaces up to the next tab stop
n := 4 - (x+xOffset)%4
for i := range n {
t.SetCell(x+i, y, ' ', efg, ebg)
}
written += n
x += n
} else {
t.SetCell(x, y, c, efg, ebg)
n := runewidth.RuneWidth(c)
x += n
written += n
}
}
if !args.Fill {
return written
}
width, _ := t.Size()
for ; x < width; x++ {
t.SetCell(x, y, ' ', fg, bg)
}
written += width - x
return written
}