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 Unverified
B5690EEEBB952194The 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`). | |||
| 45 | 45 | | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons | | |
| 46 | 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 | 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 | 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 | 50 | | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` | | |
| 51 | 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 | | |
| @@ -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 { | |||
| 576 | 576 | _, scene.Separators = sc.mon.overviewRows() | |
| 577 | 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 | 581 | for _, p := range layout { | |
| 580 | 582 | // Only the focused output has an activated window. | |
| 581 | 583 | focused := p.Focused && i == c.focusScreen | |
| @@ -583,7 +585,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 583 | 585 | if p.Peek { | |
| 584 | 586 | 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 | 589 | drawable = true | |
| 588 | 590 | if p.ID == c.pulse.id { | |
| 589 | 591 | 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.focusScreen | 583 | 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.Dim | 586 | 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 = true | 589 | drawable = true | ||
| 588 | if p.ID == c.pulse.id { | 590 | if p.ID == c.pulse.id { | ||
| 589 | sw.FocusEffect = pulse | 591 | sw.FocusEffect = pulse | ||
M internal/core/pulse.go +36 -2
| @@ -14,14 +14,17 @@ import ( | |||
| 14 | 14 | // pulseSettle before the pulse starts, a focus change stops a running one, and | |
| 15 | 15 | // the window that pulsed last does not pulse again within pulseCooldown | |
| 16 | 16 | // (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. | |
| 19 | 20 | ||
| 20 | 21 | const ( | |
| 21 | 22 | pulseSettle = 150 * time.Millisecond | |
| 22 | 23 | pulseRise = 90 * time.Millisecond | |
| 23 | 24 | pulseFall = 230 * time.Millisecond | |
| 24 | 25 | 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 | 30 | // focusPulse is the pulse state. target is the window focus last seen and | |
| @@ -93,6 +96,13 @@ func (c *Core) pulseTick() bool { | |||
| 93 | 96 | p.timerC, p.timerStop = newTimer(c.ch.Clock, wait) | |
| 94 | 97 | 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 | 106 | if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown { | |
| 97 | 107 | return false | |
| 98 | 108 | } | |
| @@ -122,6 +132,30 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||
| 122 | 132 | 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 | 159 | // pulsing reports whether a pulse runs on the screen. | |
| 126 | 160 | func (c *Core) pulsing(sc *screen) bool { | |
| 127 | 161 | 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, and | 14 | // pulseSettle before the pulse starts, a focus change stops a running one, and | ||
| 15 | // the window that pulsed last does not pulse again within pulseCooldown | 15 | // 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, overview | 17 | + | // 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.Millisecond | 22 | pulseSettle = 150 * time.Millisecond | ||
| 22 | pulseRise = 90 * time.Millisecond | 23 | pulseRise = 90 * time.Millisecond | ||
| 23 | pulseFall = 230 * time.Millisecond | 24 | pulseFall = 230 * time.Millisecond | ||
| 24 | pulseCooldown = time.Second | 25 | 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 and | 30 | // 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 false | 97 | 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 false | 107 | return false | ||
| 98 | } | 108 | } | ||
| @@ -122,6 +132,30 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||||
| 122 | return 0 | 132 | 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) { | |||
| 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 | 223 | // A popup grab or layer taking the keyboard stops the pulse; the window | |
| 168 | 224 | // getting it back is not a new focus and does not pulse again. | |
| 169 | 225 | 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 window | 223 | // 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) { | ||