detached 3b84a9a

feat(core): skip the focus pulse on a window alone on screen (#130)

* feat(core): skip the focus pulse on a window alone on screen

* fix(core): decide the focus pulse once a landing slide settles

* fix(core): hold the focus pulse while a swipe moves the layout
commit
3b84a9a84a1bb424b0e96ee94d8cdfcc2cee4df7
author
bnema · · 2026-10-04 15:02 UTC
committer
GitHub · · 2026-10-04 15:02 UTC
parent
cf370a0
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +96 -4
M docs/config.md +1 -1
@@ -45,7 +45,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4545 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
4646 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
4747 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
48-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, the overview and a window focused as it opens |
48+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens |
4949 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
5050 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
5151 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
@@ -45,7 +45,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47 | `focus.follow-move` | `off` | Follow a column moved to another workspace |47 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
48-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, the overview and a window focused as it opens |48+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens |
49 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |49 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
50 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |50 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
51 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |51 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
M internal/core/core.go +3 -1
@@ -576,6 +576,8 @@ func (c *Core) publish(ctx context.Context) error {
576576 _, scene.Separators = sc.mon.overviewRows()
577577 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
578578 }
579+ // A window alone on screen needs no pulse to show it has the focus.
580+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
579581 for _, p := range layout {
580582 // Only the focused output has an activated window.
581583 focused := p.Focused && i == c.focusScreen
@@ -583,7 +585,7 @@ func (c *Core) publish(ctx context.Context) error {
583585 if p.Peek {
584586 sw.Dim = c.cfg.Stash.Dim
585587 }
586- if focused && p.ID == c.pulse.target && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
588+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
587589 drawable = true
588590 if p.ID == c.pulse.id {
589591 sw.FocusEffect = pulse
@@ -576,6 +576,8 @@ func (c *Core) publish(ctx context.Context) error {
576 _, scene.Separators = sc.mon.overviewRows()576 _, scene.Separators = sc.mon.overviewRows()
577 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)577 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
578 }578 }
579+ // A window alone on screen needs no pulse to show it has the focus.
580+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
579 for _, p := range layout {581 for _, p := range layout {
580 // Only the focused output has an activated window.582 // Only the focused output has an activated window.
581 focused := p.Focused && i == c.focusScreen583 focused := p.Focused && i == c.focusScreen
@@ -583,7 +585,7 @@ func (c *Core) publish(ctx context.Context) error {
583 if p.Peek {585 if p.Peek {
584 sw.Dim = c.cfg.Stash.Dim586 sw.Dim = c.cfg.Stash.Dim
585 }587 }
586- if focused && p.ID == c.pulse.target && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {588+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
587 drawable = true589 drawable = true
588 if p.ID == c.pulse.id {590 if p.ID == c.pulse.id {
589 sw.FocusEffect = pulse591 sw.FocusEffect = pulse
M internal/core/pulse.go +36 -2
@@ -14,14 +14,17 @@ import (
1414 // pulseSettle before the pulse starts, a focus change stops a running one, and
1515 // the window that pulsed last does not pulse again within pulseCooldown
1616 // (switching back after another window held the focus is a new change).
17-// Fast switching therefore shows nothing. Fullscreen windows, overview
18-// previews, a window focused as it maps and a protected session never pulse.
17+// Fast switching therefore shows nothing. Fullscreen windows, a window alone
18+// on screen (e.g. a maximized column), overview previews, a window focused as
19+// it maps and a protected session never pulse.
1920
2021 const (
2122 pulseSettle = 150 * time.Millisecond
2223 pulseRise = 90 * time.Millisecond
2324 pulseFall = 230 * time.Millisecond
2425 pulseCooldown = time.Second
26+ // pulseRecheck is how often a pulse waiting for a slide to land checks again.
27+ pulseRecheck = pulseSettle / 3
2528 )
2629
2730 // focusPulse is the pulse state. target is the window focus last seen and
@@ -93,6 +96,13 @@ func (c *Core) pulseTick() bool {
9396 p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)
9497 return false
9598 }
99+ // A swipe or its landing slide still shows the neighbours: decide on
100+ // the settled layout, or a window left alone would start a pulse cut
101+ // short.
102+ if !c.cur().mon.settled() {
103+ p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
104+ return false
105+ }
96106 if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
97107 return false
98108 }
@@ -122,6 +132,30 @@ func (c *Core) advancePulse(now time.Time) float64 {
122132 return 0
123133 }
124134
135+// settled reports whether no swipe or slide moves the monitor's layout.
136+func (m *Monitor) settled() bool {
137+ if m.switchMotion != nil || m.switchOff != 0 {
138+ return false
139+ }
140+ for w := range m.all() {
141+ if w.motion != nil || w.shift != 0 {
142+ return false
143+ }
144+ }
145+ return true
146+}
147+
148+// visibleCount is the number of windows of layout shown in frame.
149+func visibleCount(layout []Placement, frame Rect) int {
150+ n := 0
151+ for _, p := range layout {
152+ if p.Preview == 0 && onScreen(p, frame) {
153+ n++
154+ }
155+ }
156+ return n
157+}
158+
125159 // pulsing reports whether a pulse runs on the screen.
126160 func (c *Core) pulsing(sc *screen) bool {
127161 return c.pulse.id != 0 && sc == c.cur()
@@ -14,14 +14,17 @@ import (
14 // pulseSettle before the pulse starts, a focus change stops a running one, and14 // pulseSettle before the pulse starts, a focus change stops a running one, and
15 // the window that pulsed last does not pulse again within pulseCooldown15 // the window that pulsed last does not pulse again within pulseCooldown
16 // (switching back after another window held the focus is a new change).16 // (switching back after another window held the focus is a new change).
17-// Fast switching therefore shows nothing. Fullscreen windows, overview17+// Fast switching therefore shows nothing. Fullscreen windows, a window alone
18-// previews, a window focused as it maps and a protected session never pulse.18+// on screen (e.g. a maximized column), overview previews, a window focused as
19+// it maps and a protected session never pulse.
19 20
20 const (21 const (
21 pulseSettle = 150 * time.Millisecond22 pulseSettle = 150 * time.Millisecond
22 pulseRise = 90 * time.Millisecond23 pulseRise = 90 * time.Millisecond
23 pulseFall = 230 * time.Millisecond24 pulseFall = 230 * time.Millisecond
24 pulseCooldown = time.Second25 pulseCooldown = time.Second
26+ // pulseRecheck is how often a pulse waiting for a slide to land checks again.
27+ pulseRecheck = pulseSettle / 3
25 )28 )
26 29
27 // focusPulse is the pulse state. target is the window focus last seen and30 // focusPulse is the pulse state. target is the window focus last seen and
@@ -93,6 +96,13 @@ func (c *Core) pulseTick() bool {
93 p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)96 p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)
94 return false97 return false
95 }98 }
99+ // A swipe or its landing slide still shows the neighbours: decide on
100+ // the settled layout, or a window left alone would start a pulse cut
101+ // short.
102+ if !c.cur().mon.settled() {
103+ p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
104+ return false
105+ }
96 if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {106 if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
97 return false107 return false
98 }108 }
@@ -122,6 +132,30 @@ func (c *Core) advancePulse(now time.Time) float64 {
122 return 0132 return 0
123 }133 }
124 134
135+// settled reports whether no swipe or slide moves the monitor's layout.
136+func (m *Monitor) settled() bool {
137+ if m.switchMotion != nil || m.switchOff != 0 {
138+ return false
139+ }
140+ for w := range m.all() {
141+ if w.motion != nil || w.shift != 0 {
142+ return false
143+ }
144+ }
145+ return true
146+}
147+
148+// visibleCount is the number of windows of layout shown in frame.
149+func visibleCount(layout []Placement, frame Rect) int {
150+ n := 0
151+ for _, p := range layout {
152+ if p.Preview == 0 && onScreen(p, frame) {
153+ n++
154+ }
155+ }
156+ return n
157+}
158+
125 // pulsing reports whether a pulse runs on the screen.159 // pulsing reports whether a pulse runs on the screen.
126 func (c *Core) pulsing(sc *screen) bool {160 func (c *Core) pulsing(sc *screen) bool {
127 return c.pulse.id != 0 && sc == c.cur()161 return c.pulse.id != 0 && sc == c.cur()
M internal/core/pulse_test.go +56 -0
@@ -164,6 +164,62 @@ func TestFocusPulseSkipsFullscreen(t *testing.T) {
164164 }
165165 }
166166
167+// A window alone on screen (a maximized column here) needs no pulse; once
168+// another window shows beside it, focusing it pulses again.
169+func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
170+ c, ic := pulseCore(t)
171+ c.cur().mon.Current().FocusID(1)
172+ c.cur().mon.Current().ToggleFullWidth()
173+ indicatorScene(t, c)
174+ ic.now = ic.now.Add(pulseSettle)
175+ if c.pulseTick() || c.animating() {
176+ t.Fatal("a window alone on screen pulses")
177+ }
178+ c.cur().mon.Current().ToggleFullWidth()
179+ c.cur().mon.Current().FocusID(2)
180+ indicatorScene(t, c)
181+ ic.now = ic.now.Add(pulseSettle)
182+ if !c.pulseTick() || c.pulse.id != 2 {
183+ t.Fatal("a window beside another did not pulse")
184+ }
185+}
186+
187+// A swipe or its landing slide still shows the neighbours: the pulse waits
188+// for the layout to settle, then decides on it.
189+func TestFocusPulseWaitsForSlide(t *testing.T) {
190+ for _, tc := range []struct {
191+ name string
192+ slide func(*Monitor, time.Time)
193+ alone bool
194+ }{
195+ {"workspace landing, alone", func(m *Monitor, now time.Time) { m.switchMotion = newMotion(workspaceSpring(0.5, 0), now) }, true},
196+ {"workspace drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true},
197+ {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false},
198+ } {
199+ t.Run(tc.name, func(t *testing.T) {
200+ c, ic := pulseCore(t)
201+ m := c.cur().mon
202+ m.Current().FocusID(1)
203+ indicatorScene(t, c)
204+ tc.slide(m, ic.now)
205+ ic.now = ic.now.Add(pulseSettle)
206+ if c.pulseTick() || c.pulse.timerC == nil {
207+ t.Fatal("pulse decided during a slide")
208+ }
209+ m.stopSwitch()
210+ m.Current().stopSlide()
211+ if tc.alone {
212+ m.Current().ToggleFullWidth()
213+ }
214+ indicatorScene(t, c)
215+ ic.now = ic.now.Add(pulseRecheck)
216+ if got := c.pulseTick(); got == tc.alone {
217+ t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
218+ }
219+ })
220+ }
221+}
222+
167223 // A popup grab or layer taking the keyboard stops the pulse; the window
168224 // getting it back is not a new focus and does not pulse again.
169225 func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
@@ -164,6 +164,62 @@ func TestFocusPulseSkipsFullscreen(t *testing.T) {
164 }164 }
165 }165 }
166 166
167+// A window alone on screen (a maximized column here) needs no pulse; once
168+// another window shows beside it, focusing it pulses again.
169+func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
170+ c, ic := pulseCore(t)
171+ c.cur().mon.Current().FocusID(1)
172+ c.cur().mon.Current().ToggleFullWidth()
173+ indicatorScene(t, c)
174+ ic.now = ic.now.Add(pulseSettle)
175+ if c.pulseTick() || c.animating() {
176+ t.Fatal("a window alone on screen pulses")
177+ }
178+ c.cur().mon.Current().ToggleFullWidth()
179+ c.cur().mon.Current().FocusID(2)
180+ indicatorScene(t, c)
181+ ic.now = ic.now.Add(pulseSettle)
182+ if !c.pulseTick() || c.pulse.id != 2 {
183+ t.Fatal("a window beside another did not pulse")
184+ }
185+}
186+
187+// A swipe or its landing slide still shows the neighbours: the pulse waits
188+// for the layout to settle, then decides on it.
189+func TestFocusPulseWaitsForSlide(t *testing.T) {
190+ for _, tc := range []struct {
191+ name string
192+ slide func(*Monitor, time.Time)
193+ alone bool
194+ }{
195+ {"workspace landing, alone", func(m *Monitor, now time.Time) { m.switchMotion = newMotion(workspaceSpring(0.5, 0), now) }, true},
196+ {"workspace drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true},
197+ {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false},
198+ } {
199+ t.Run(tc.name, func(t *testing.T) {
200+ c, ic := pulseCore(t)
201+ m := c.cur().mon
202+ m.Current().FocusID(1)
203+ indicatorScene(t, c)
204+ tc.slide(m, ic.now)
205+ ic.now = ic.now.Add(pulseSettle)
206+ if c.pulseTick() || c.pulse.timerC == nil {
207+ t.Fatal("pulse decided during a slide")
208+ }
209+ m.stopSwitch()
210+ m.Current().stopSlide()
211+ if tc.alone {
212+ m.Current().ToggleFullWidth()
213+ }
214+ indicatorScene(t, c)
215+ ic.now = ic.now.Add(pulseRecheck)
216+ if got := c.pulseTick(); got == tc.alone {
217+ t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
218+ }
219+ })
220+ }
221+}
222+
167 // A popup grab or layer taking the keyboard stops the pulse; the window223 // A popup grab or layer taking the keyboard stops the pulse; the window
168 // getting it back is not a new focus and does not pulse again.224 // getting it back is not a new focus and does not pulse again.
169 func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {225 func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close