mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-12 16:46:25 -04:00
Integration tests synchronize with lazygit through the task manager: after submitting an input event, the test driver waits until the program goes idle before asserting. But a submitted event only got its task once the main loop picked it up from the events channel; while it was still in flight (handed to the poller goroutine, or sitting in the channel), no task existed for it, so the program could look idle even though input was still pending. The edge-triggered idle protocol mostly papers over this: each wait is satisfied by the *next* busy-to-idle transition, which in practice is the one produced by processing the submitted event. It only goes wrong when some other task (e.g. a background refresh) completes in that window, producing an edge the waiting test mistakes for its own — a rare source of test flakes. The next commit replaces that protocol with a level-triggered one, for which the window would be fatal rather than rare: a wait falling into the gap would return immediately. Close the gap by creating the task on the test goroutine before the event is submitted, and carrying it through the poller into the main loop, which uses it instead of creating its own. The new Replay* methods own this invariant, and the replayed-events channels are no longer exported, so tests can't submit an untracked event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
442 lines
10 KiB
Go
442 lines
10 KiB
Go
// Copyright 2020 The gocui Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package gocui
|
|
|
|
import (
|
|
"github.com/gdamore/tcell/v3"
|
|
"github.com/gdamore/tcell/v3/vt"
|
|
)
|
|
|
|
// We probably don't want this being a global variable for YOLO for now
|
|
var Screen tcell.Screen
|
|
|
|
// oldStyle is a representation of how a cell would be styled when we were using termbox
|
|
type oldStyle struct {
|
|
fg Attribute
|
|
bg Attribute
|
|
outputMode OutputMode
|
|
}
|
|
|
|
var runeReplacements = map[rune]string{
|
|
'┌': "+",
|
|
'┐': "+",
|
|
'└': "+",
|
|
'┘': "+",
|
|
'╭': "+",
|
|
'╮': "+",
|
|
'╰': "+",
|
|
'╯': "+",
|
|
'─': "-",
|
|
'═': "-",
|
|
'║': "|",
|
|
'╔': "+",
|
|
'╗': "+",
|
|
'╚': "+",
|
|
'╝': "+",
|
|
|
|
// using a hyphen here actually looks weird.
|
|
// We see these characters when in portrait mode
|
|
'╶': " ",
|
|
'╴': " ",
|
|
|
|
'┴': "+",
|
|
'┬': "+",
|
|
'╷': "|",
|
|
'├': "+",
|
|
'│': "|",
|
|
'▼': "v",
|
|
'►': ">",
|
|
'▲': "^",
|
|
'◄': "<",
|
|
}
|
|
|
|
// tcellInit initializes tcell screen for use.
|
|
func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
|
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
|
|
|
|
s, e := tcell.NewScreen()
|
|
if e != nil {
|
|
return e
|
|
}
|
|
|
|
if e = s.Init(); e != nil {
|
|
return e
|
|
}
|
|
|
|
registerRuneFallbacks(s, runeReplacements)
|
|
|
|
g.screen = s
|
|
Screen = s
|
|
return nil
|
|
}
|
|
|
|
func registerRuneFallbacks(s tcell.Screen, additional map[rune]string) {
|
|
for before, after := range runeReplacements {
|
|
s.RegisterRuneFallback(before, after)
|
|
}
|
|
|
|
for before, after := range additional {
|
|
s.RegisterRuneFallback(before, after)
|
|
}
|
|
}
|
|
|
|
// tcellInitSimulation initializes tcell screen for use.
|
|
func (g *Gui) tcellInitSimulation(width int, height int) error {
|
|
mt := vt.NewMockTerm(vt.MockOptSize{X: vt.Col(width), Y: vt.Row(height)})
|
|
s, e := tcell.NewTerminfoScreenFromTty(mt)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if e = s.Init(); e != nil {
|
|
return e
|
|
}
|
|
|
|
g.screen = s
|
|
Screen = s
|
|
s.Sync()
|
|
return nil
|
|
}
|
|
|
|
// tcellSetCell sets the character cell at a given location to the given
|
|
// content (grapheme cluster) and attributes using provided OutputMode
|
|
func tcellSetCell(x, y int, ch string, fg, bg Attribute, outputMode OutputMode) {
|
|
st := getTcellStyle(oldStyle{fg: fg, bg: bg, outputMode: outputMode})
|
|
Screen.Put(x, y, ch, st)
|
|
}
|
|
|
|
// getTcellStyle creates tcell.Style from Attributes
|
|
func getTcellStyle(input oldStyle) tcell.Style {
|
|
st := tcell.StyleDefault
|
|
|
|
// extract colors and attributes
|
|
if input.fg != ColorDefault {
|
|
st = st.Foreground(getTcellColor(input.fg, input.outputMode))
|
|
st = setTcellFontEffectStyle(st, input.fg)
|
|
}
|
|
if input.bg != ColorDefault {
|
|
st = st.Background(getTcellColor(input.bg, input.outputMode))
|
|
st = setTcellFontEffectStyle(st, input.bg)
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
// setTcellFontEffectStyle add additional attributes to tcell.Style
|
|
func setTcellFontEffectStyle(st tcell.Style, attr Attribute) tcell.Style {
|
|
if attr&AttrBold != 0 {
|
|
st = st.Bold(true)
|
|
}
|
|
if attr&AttrUnderline != 0 {
|
|
st = st.Underline(true)
|
|
}
|
|
if attr&AttrReverse != 0 {
|
|
st = st.Reverse(true)
|
|
}
|
|
if attr&AttrBlink != 0 {
|
|
st = st.Blink(true)
|
|
}
|
|
if attr&AttrDim != 0 {
|
|
st = st.Dim(true)
|
|
}
|
|
if attr&AttrItalic != 0 {
|
|
st = st.Italic(true)
|
|
}
|
|
if attr&AttrStrikeThrough != 0 {
|
|
st = st.StrikeThrough(true)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// gocuiEventType represents the type of event.
|
|
type gocuiEventType uint8
|
|
|
|
// GocuiEvent represents events like a keys, mouse actions, or window resize.
|
|
//
|
|
// The 'Mod', 'Key' and 'Ch' fields are valid if 'Type' is 'eventKey'.
|
|
// The 'MouseX' and 'MouseY' fields are valid if 'Type' is 'eventMouse'.
|
|
// The 'Width' and 'Height' fields are valid if 'Type' is 'eventResize'.
|
|
// The 'Focused' field is valid if 'Type' is 'eventFocus'.
|
|
// The 'Start' field is valid if 'Type' is 'eventPaste'. It is true for the
|
|
// beginning of a paste operation, false for the end.
|
|
// The 'Err' field is valid if 'Type' is 'eventError'.
|
|
type GocuiEvent struct {
|
|
Type gocuiEventType
|
|
Key Key
|
|
Width int
|
|
Height int
|
|
Err error
|
|
MouseX int
|
|
MouseY int
|
|
Focused bool
|
|
Start bool
|
|
N int
|
|
|
|
// task tracks the processing of this event for idle detection. Events
|
|
// replayed by integration tests carry a task from the moment they are
|
|
// submitted (see Gui.ReplayKeyEvent); for organic events it is nil, and
|
|
// the main loop creates a task when it picks the event up.
|
|
task Task
|
|
}
|
|
|
|
// Event types.
|
|
const (
|
|
eventNone gocuiEventType = iota
|
|
eventKey
|
|
eventResize
|
|
eventMouse
|
|
eventMouseMove // only used when no button is down, otherwise it's eventMouse
|
|
eventFocus
|
|
eventPaste
|
|
eventInterrupt
|
|
eventError
|
|
eventRaw
|
|
)
|
|
|
|
const (
|
|
NOT_DRAGGING int = iota
|
|
MAYBE_DRAGGING
|
|
DRAGGING
|
|
)
|
|
|
|
var (
|
|
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
|
|
lastMouseMod tcell.ModMask = tcell.ModNone
|
|
dragState = NOT_DRAGGING
|
|
lastX = 0
|
|
lastY = 0
|
|
)
|
|
|
|
// this wrapper struct has public keys so we can easily serialize/deserialize to JSON
|
|
type TcellKeyEventWrapper struct {
|
|
Timestamp int64
|
|
Mod tcell.ModMask
|
|
Key tcell.Key
|
|
Ch string
|
|
|
|
task Task // see GocuiEvent.task
|
|
}
|
|
|
|
func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper {
|
|
return &TcellKeyEventWrapper{
|
|
Timestamp: timestamp,
|
|
Mod: event.Modifiers(),
|
|
Key: event.Key(),
|
|
Ch: event.Str(),
|
|
}
|
|
}
|
|
|
|
func (wrapper TcellKeyEventWrapper) toTcellEvent() tcell.Event {
|
|
return tcell.NewEventKey(wrapper.Key, wrapper.Ch, wrapper.Mod)
|
|
}
|
|
|
|
type TcellMouseEventWrapper struct {
|
|
Timestamp int64
|
|
X int
|
|
Y int
|
|
ButtonMask tcell.ButtonMask
|
|
ModMask tcell.ModMask
|
|
|
|
task Task // see GocuiEvent.task
|
|
}
|
|
|
|
func NewTcellMouseEventWrapper(event *tcell.EventMouse, timestamp int64) *TcellMouseEventWrapper {
|
|
x, y := event.Position()
|
|
return &TcellMouseEventWrapper{
|
|
Timestamp: timestamp,
|
|
X: x,
|
|
Y: y,
|
|
ButtonMask: event.Buttons(),
|
|
ModMask: event.Modifiers(),
|
|
}
|
|
}
|
|
|
|
func (wrapper TcellMouseEventWrapper) toTcellEvent() tcell.Event {
|
|
return tcell.NewEventMouse(wrapper.X, wrapper.Y, wrapper.ButtonMask, wrapper.ModMask)
|
|
}
|
|
|
|
type TcellResizeEventWrapper struct {
|
|
Timestamp int64
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
func NewTcellResizeEventWrapper(event *tcell.EventResize, timestamp int64) *TcellResizeEventWrapper {
|
|
w, h := event.Size()
|
|
|
|
return &TcellResizeEventWrapper{
|
|
Timestamp: timestamp,
|
|
Width: w,
|
|
Height: h,
|
|
}
|
|
}
|
|
|
|
func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event {
|
|
return tcell.NewEventResize(wrapper.Width, wrapper.Height)
|
|
}
|
|
|
|
type TcellFocusEventWrapper struct {
|
|
Timestamp int64
|
|
Focused bool
|
|
|
|
task Task // see GocuiEvent.task
|
|
}
|
|
|
|
func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper {
|
|
return &TcellFocusEventWrapper{
|
|
Timestamp: timestamp,
|
|
Focused: event.Focused,
|
|
}
|
|
}
|
|
|
|
func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
|
|
return tcell.NewEventFocus(wrapper.Focused)
|
|
}
|
|
|
|
// pollEvent get tcell.Event and transform it into gocuiEvent
|
|
func (g *Gui) pollEvent() GocuiEvent {
|
|
var tev tcell.Event
|
|
var task Task
|
|
if g.playRecording {
|
|
select {
|
|
case ev := <-g.replayedEvents.Keys:
|
|
tev = (ev).toTcellEvent()
|
|
task = ev.task
|
|
case ev := <-g.replayedEvents.Resizes:
|
|
tev = (ev).toTcellEvent()
|
|
case ev := <-g.replayedEvents.MouseEvents:
|
|
tev = (ev).toTcellEvent()
|
|
task = ev.task
|
|
case ev := <-g.replayedEvents.FocusEvents:
|
|
tev = (ev).toTcellEvent()
|
|
task = ev.task
|
|
}
|
|
} else {
|
|
tev = <-Screen.EventQ()
|
|
}
|
|
|
|
event := gocuiEventFromTcellEvent(tev)
|
|
event.task = task
|
|
return event
|
|
}
|
|
|
|
func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
|
switch tev := tev.(type) {
|
|
case *tcell.EventInterrupt:
|
|
return GocuiEvent{Type: eventInterrupt}
|
|
case *tcell.EventResize:
|
|
w, h := tev.Size()
|
|
return GocuiEvent{Type: eventResize, Width: w, Height: h}
|
|
case *tcell.EventKey:
|
|
k := tev.Key()
|
|
ch := ""
|
|
if k == tcell.KeyRune {
|
|
ch = tev.Str()
|
|
} else if k >= tcell.KeyCtrlA && k <= tcell.KeyCtrlZ {
|
|
ch = string(rune('a' + (k - tcell.KeyCtrlA)))
|
|
k = tcell.KeyRune
|
|
}
|
|
mod := tev.Modifiers()
|
|
|
|
return GocuiEvent{
|
|
Type: eventKey,
|
|
Key: NewKey(KeyName(k), ch, Modifier(mod)),
|
|
}
|
|
case *tcell.EventMouse:
|
|
x, y := tev.Position()
|
|
button := tev.Buttons()
|
|
mouseKey := MouseRelease
|
|
mouseMod := ModNone
|
|
// process mouse wheel
|
|
if button&tcell.WheelUp != 0 {
|
|
mouseKey = MouseWheelUp
|
|
}
|
|
if button&tcell.WheelDown != 0 {
|
|
mouseKey = MouseWheelDown
|
|
}
|
|
if button&tcell.WheelLeft != 0 {
|
|
mouseKey = MouseWheelLeft
|
|
}
|
|
if button&tcell.WheelRight != 0 {
|
|
mouseKey = MouseWheelRight
|
|
}
|
|
|
|
wheeling := mouseKey == MouseWheelUp || mouseKey == MouseWheelDown || mouseKey == MouseWheelLeft || mouseKey == MouseWheelRight
|
|
|
|
// process button events (not wheel events)
|
|
button &= tcell.ButtonMask(0xff)
|
|
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
|
|
lastMouseKey = button
|
|
lastMouseMod = tev.Modifiers()
|
|
switch button {
|
|
case tcell.ButtonPrimary:
|
|
mouseKey = MouseLeft
|
|
dragState = MAYBE_DRAGGING
|
|
lastX = x
|
|
lastY = y
|
|
case tcell.ButtonSecondary:
|
|
mouseKey = MouseRight
|
|
case tcell.ButtonMiddle:
|
|
mouseKey = MouseMiddle
|
|
default:
|
|
}
|
|
}
|
|
|
|
switch tev.Buttons() {
|
|
case tcell.ButtonNone:
|
|
if lastMouseKey != tcell.ButtonNone {
|
|
switch lastMouseKey {
|
|
case tcell.ButtonPrimary:
|
|
dragState = NOT_DRAGGING
|
|
case tcell.ButtonSecondary:
|
|
case tcell.ButtonMiddle:
|
|
default:
|
|
}
|
|
mouseMod = Modifier(lastMouseMod)
|
|
lastMouseMod = tcell.ModNone
|
|
lastMouseKey = tcell.ButtonNone
|
|
}
|
|
default:
|
|
}
|
|
|
|
if !wheeling {
|
|
switch dragState {
|
|
case NOT_DRAGGING:
|
|
return GocuiEvent{
|
|
Type: eventMouseMove,
|
|
MouseX: x,
|
|
MouseY: y,
|
|
}
|
|
// if we haven't released the left mouse button and we've moved the cursor then we're dragging
|
|
case MAYBE_DRAGGING:
|
|
if x != lastX || y != lastY {
|
|
dragState = DRAGGING
|
|
}
|
|
case DRAGGING:
|
|
mouseMod = ModMotion
|
|
mouseKey = MouseLeft
|
|
}
|
|
}
|
|
|
|
return GocuiEvent{
|
|
Type: eventMouse,
|
|
MouseX: x,
|
|
MouseY: y,
|
|
Key: NewKey(mouseKey, "", mouseMod),
|
|
}
|
|
case *tcell.EventFocus:
|
|
return GocuiEvent{
|
|
Type: eventFocus,
|
|
Focused: tev.Focused,
|
|
}
|
|
case *tcell.EventPaste:
|
|
return GocuiEvent{
|
|
Type: eventPaste,
|
|
Start: tev.Start(),
|
|
}
|
|
default:
|
|
return GocuiEvent{Type: eventNone}
|
|
}
|
|
}
|