From ad533bb130e8f5e9e2cf8573839350e621cae4e2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 20 Aug 2026 10:19:07 +0200 Subject: [PATCH] Give views a transient line flash Actions that hand control to another application need visible acknowledgement without moving a view's cursor or replacing renderer colors. Reverse the narrow selection bar independently of selection state, and clear transient flashes whenever the terminal UI suspends. --- pkg/gocui/flush_test.go | 17 +++++++++++++++++ pkg/gocui/gui.go | 3 +++ pkg/gocui/suspend_test.go | 11 +++++++++++ pkg/gocui/view.go | 27 +++++++++++++++++++++++++++ pkg/gocui/view_test.go | 26 ++++++++++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/pkg/gocui/flush_test.go b/pkg/gocui/flush_test.go index b4f42afc4..d5d6b585f 100644 --- a/pkg/gocui/flush_test.go +++ b/pkg/gocui/flush_test.go @@ -81,6 +81,23 @@ func TestFlushContentOnly_WritesCorrectContent(t *testing.T) { assert.Equal(t, "Fetching |", status.Buffer()) } +func TestForceFlushViewsContentOnlyDrawsLineFlash(t *testing.T) { + g := newTestGui(t) + _, main := setupViews(t, g) + main.Highlight = true + main.SelBgColor = ColorBlue + main.SelectedLineColorWidth = 2 + main.FocusPoint(0, 0, false) + + main.SetLineFlash(0) + g.ForceFlushViewsContentOnly(g.Views()) + + for x := main.x0 + 1; x <= main.x0+2; x++ { + _, style, _ := Screen.Get(x, main.y0+1) + assert.True(t, style.HasReverse(), "selection-bar cell at x=%d should flash", x) + } +} + func TestProcessEvent_ContentOnlyEvent_SkipsTaintedCheck(t *testing.T) { g := newTestGui(t) status, main := setupViews(t, g) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index f4f87a281..465a7b48e 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -2149,6 +2149,9 @@ func (g *Gui) Suspend() error { return errors.New("Already suspended") } + for _, view := range g.views { + view.ClearLineFlash() + } g.suspended = true if err := g.screen.Suspend(); err != nil { diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go index 5a7a070d6..28d51ffac 100644 --- a/pkg/gocui/suspend_test.go +++ b/pkg/gocui/suspend_test.go @@ -67,3 +67,14 @@ func TestResumeSchedulesRedraw(t *testing.T) { assert.Equal(t, eventResize, ev.Type, "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") } + +func TestSuspendClearsLineFlashes(t *testing.T) { + g := newTestGui(t) + v, err := g.SetView("main", 0, 0, 20, 10, 0) + assert.ErrorIs(t, err, ErrUnknownView) + v.SetLineFlash(3) + + assert.NoError(t, g.Suspend()) + assert.Equal(t, -1, v.lineFlashY) + assert.NoError(t, g.Resume()) +} diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index fa5eb934d..b195768dc 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -80,6 +80,10 @@ type View struct { // a user starts a range select and then moves the cursor up. rangeSelectStartY int + // The view line whose selection-width bar is temporarily reversed. A value + // of -1 means that no line is flashing. + lineFlashY int + // readBuffer is used for storing unread bytes readBuffer []byte @@ -811,6 +815,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View { searcher: &searcher{}, TextArea: &TextArea{}, rangeSelectStartY: -1, + lineFlashY: -1, TabWidth: 4, } @@ -970,6 +975,10 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW fgColor |= AttrUnderline } + if v.lineFlashY == v.oy+y && (v.SelectedLineColorWidth == 0 || x < v.SelectedLineColorWidth) { + fgColor ^= AttrReverse + } + // Don't display empty characters if ch == "" { ch = " " @@ -2323,6 +2332,24 @@ func applySelTextColor(fgColor, selTextColor Attribute) Attribute { return fgColor | selTextColor&AttrStyleBits } +// SetLineFlash temporarily marks a view line without moving or changing the +// selection. The caller owns the lifetime and clears it with ClearLineFlash. +func (v *View) SetLineFlash(viewLine int) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.lineFlashY = viewLine + v.needsRedraw = true +} + +func (v *View) ClearLineFlash() { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.lineFlashY = -1 + v.needsRedraw = true +} + func lineWrap(line []cell, columns int) [][]cell { if columns == 0 { return [][]cell{line} diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 0ce688aa6..5731d853e 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1113,6 +1113,32 @@ func TestSelectedLinesOfWrappedContent(t *testing.T) { assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines()) } +func TestLineFlashReversesTheSelectionBarWithoutChangingSelection(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Highlight = true + v.SelBgColor = ColorBlue + v.SelectedLineColorWidth = 2 + v.writeString("one\ntwo\nthree\n") + v.FocusPoint(0, 1, false) + v.SetLineFlash(1) + v.draw(true) + + for x := 1; x <= 2; x++ { + _, style, _ := Screen.Get(x, 2) + assert.True(t, style.HasReverse(), "selection-bar cell at (%d, 2) should flash", x) + } + _, style, _ := Screen.Get(3, 2) + assert.False(t, style.HasReverse(), "the flash should stop after the selection bar") + assert.Equal(t, "two", v.SelectedLine(), "flashing should not change the selection") + + v.ClearLineFlash() + v.draw(true) + _, style, _ = Screen.Get(1, 2) + assert.False(t, style.HasReverse(), "clearing should remove the flash") +} + // Resizing a view throws away the wrapping of its content and wraps it again for // the new width, which moves every line of it to a different view line. The // positions into the view count view lines, so they all have to come along.