Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ type Model struct {
// showHelp overlays the key map.
showHelp bool

// A trace-closing Escape is briefly deferred because terminal navigation
// keys are ESC-prefixed sequences which can be split across input reads.
traceClosePending bool
traceCloseSequence uint64

// filter narrows the list on screen. filterFocused is whether it is being
// typed into; a filter stays in force after the cursor leaves it. Search
// and Logs share a value, while each trace page keeps its own so a URI
Expand Down
14 changes: 13 additions & 1 deletion pkg/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

// Handle key presses.
case tea.KeyMsg:
// Bubble Tea can emit the ESC byte of an arrow/wheel sequence as a
// standalone key when the terminal splits the sequence across reads.
if m.cancelTraceCloseForContinuation(msg) {
return m, nil
}

// While the filter has the cursor almost every key is text, so it is
// asked first and only the keys which leave it are taken away.
if m.filterFocused {
Expand Down Expand Up @@ -63,11 +69,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

if m.showHelp || m.inTrace() {
if m.showHelp {
return m.updateKeyEsc()
}
if m.inTrace() {
return m.deferTraceClose()
}
}

case deferredTraceCloseMsg:
return m.updateDeferredTraceClose(msg)

case tea.WindowSizeMsg:
return m.updateWindowSize(msg)

Expand Down
50 changes: 50 additions & 0 deletions pkg/app/update_key_esc.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,61 @@
package app

import (
"time"

tea "github.com/charmbracelet/bubbletea"
)

const traceCloseEscapeWait = 50 * time.Millisecond

type deferredTraceCloseMsg struct {
sequence uint64
}

// deferTraceClose gives the remainder of an ESC-prefixed terminal navigation
// sequence time to arrive before treating its first byte as a standalone Esc.
func (m *Model) deferTraceClose() (tea.Model, tea.Cmd) {
m.traceCloseSequence++
m.traceClosePending = true
sequence := m.traceCloseSequence

return m, tea.Tick(traceCloseEscapeWait, func(time.Time) tea.Msg {
return deferredTraceCloseMsg{sequence: sequence}
})
}

// cancelTraceCloseForContinuation consumes the suffix left behind when Bubble
// Tea receives an ANSI sequence in a separate read from its leading ESC byte.
func (m *Model) cancelTraceCloseForContinuation(msg tea.KeyMsg) bool {
if !m.traceClosePending || msg.Type != tea.KeyRunes || len(msg.Runes) == 0 {
return false
}

if msg.Runes[0] != '[' && msg.Runes[0] != 'O' {
return false
}

m.traceClosePending = false
m.traceCloseSequence++

return true
}

// updateDeferredTraceClose closes the trace only if no ANSI continuation has
// invalidated the timer which was created for this Escape key.
func (m *Model) updateDeferredTraceClose(msg deferredTraceCloseMsg) (tea.Model, tea.Cmd) {
if !m.traceClosePending || msg.sequence != m.traceCloseSequence || !m.inTrace() {
return m, nil
}

return m.updateKeyEsc()
}

// updateKeyEsc closes whatever is open: the help overlay first, then the trace.
func (m *Model) updateKeyEsc() (tea.Model, tea.Cmd) {
m.traceClosePending = false
m.traceCloseSequence++

if m.showHelp {
m.showHelp = false

Expand Down
35 changes: 35 additions & 0 deletions pkg/app/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -159,3 +160,37 @@ func newNavTestModel() *Model {

return m
}

// Arrow and wheel input is an ESC-prefixed ANSI sequence. Bubble Tea can split
// it into a standalone Esc and a rune suffix when the terminal read fragments,
// which must not close the trace.
func TestUpdate_FragmentedArrowDoesNotCloseTrace(t *testing.T) {
m := openedTrace(t)

_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
require.NotNil(t, cmd)
assert.Equal(t, PageFunctions, m.PageSelected)
assert.True(t, m.traceClosePending)
sequence := m.traceCloseSequence

m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[B")})
assert.Equal(t, PageFunctions, m.PageSelected)
assert.False(t, m.traceClosePending)

// The already-scheduled close must also be harmless after cancellation.
m.Update(deferredTraceCloseMsg{sequence: sequence})
assert.Equal(t, PageFunctions, m.PageSelected)
require.NotNil(t, m.Current)
}

func TestUpdate_StandaloneEscStillReturnsToSearch(t *testing.T) {
m := openedTrace(t)

_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
require.NotNil(t, cmd)
assert.Equal(t, PageFunctions, m.PageSelected)

m.Update(cmd())
assert.Equal(t, PageSearch, m.PageSelected)
assert.False(t, m.traceClosePending)
}
Loading