detached 13c6a90

feat: subtle focus indicator pulse on newly focused windows (#87)

* feat(render): add a subtle focus pulse on the newly focused window

A window that keeps the keyboard focus for 150 ms gets a short contrast
pulse (90 ms rise, 230 ms fall, 8% peak gain) drawn by compose.frag from
an unused push-constant slot. Faster switching shows nothing, a focus
change stops a running pulse, and a window does not pulse again within
one second. Fullscreen windows, the overview, a protected session and
HDR outputs skip it; a pulsing window leaves the overlay plane.
focus.pulse selects the effect: contrast (default) or off.

* fix(core): request pulse frames only for a visible window focus

The pulse follows the window focus instead of the keyboard holder: a
popup grab, layer or lock surface stops it, and the window getting the
keyboard back does not pulse again. A pulse the scene cannot show
(fullscreen, overview) ends at once, so it never drives frames, and
enabling focus.pulse does not pulse the window already focused. Core
timers share one newTimer helper.

* fix(render): draw the focus pulse on HDR outputs

compose_hdr.frag applies the same contrast to sRGB content before it is
decoded to linear light, so the pulse looks as on an SDR output. PQ and
extended-linear content stay untouched.

* feat(app): log the focus pulse window and gain in scene debug entries

* feat(render): lift the focus pulse toward white instead of contrast

Contrast around mid grey left black backgrounds unchanged, so the pulse
was invisible on dark terminals. The shaders now apply a light screen
blend, rgb + k * (1 - rgb), peaking at 4%: black shows about 10/255 and
white never saturates.

* feat(config): add focus.pulse-strength for the focus pulse peak

The peak lift toward white is read from focus.pulse-strength (default
0.04), bounded to 0.01..0.2 so the pulse cannot flash.

* feat(config): name the focus pulse effect screen, default strength 0.03

focus.pulse takes screen or off, after the blend the shaders apply. The
default peak drops to 0.03: black reaches about 8/255.

* refactor(config): split the focus indicator into animation, effect and strength

focus.animation (pulse or off) shapes in time the focus.effect (screen)
drawn at focus.strength, so new animations and effects each add one
value. SceneWindow.Pulse becomes FocusEffect.

* docs(ports): note that the focus effect kind is not carried yet

* feat(config): raise the default focus.strength to 0.04

* fix(core): skip the focus pulse for a window focused as it maps

Opening an app focuses it before its first frames arrive: a window
focused within pulseSettle of its mapping no longer pulses. Its focus
counts again from the next change.

* fix(core): address focus indicator review findings

The HDR pulse test uses a dark input, which separates a lift before
sRGB decoding from one in linear light. A security change also clears
the pulse's drawable state, so a settle timer firing with it starts
nothing. newTimer moves to core.go next to now. Comments and docs
describe the windows skipped (one focused as it opens) and the cooldown
of the last pulsed window; a test covers a window focused after it mapped.
commit
13c6a90a5f47e09460b31726db834fac2875911b
author
bnema · · 2026-10-02 18:26 UTC
committer
GitHub · · 2026-10-02 18:26 UTC
parent
f4eb83f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.0
26 files · +560 -27
M docs/config.md +3 -0
@@ -39,6 +39,9 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
3939 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |
4040 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |
4141 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
42+| `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 |
43+| `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 |
44+| `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
4245 | `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 |
4346 | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects |
4447 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
@@ -39,6 +39,9 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
39 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |39 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |
40 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |40 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |
41 | `focus.follow-move` | `off` | Follow a column moved to another workspace |41 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
42+| `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 |
43+| `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 |
44+| `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
42 | `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 | `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 |
43 | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects |46 | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects |
44 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |47 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
M examples/config +6 -0
@@ -59,6 +59,12 @@ touchpad.scroll-factor = 1
5959
6060 # on: moving a column or window to another workspace shows that workspace.
6161 focus.follow-move = off
62+# Focus indicator on the newly focused window: an animation (pulse or off)
63+# of an effect (screen: a slight brightening), peaking at strength
64+# (0.01 to 0.2; 0.04: black reaches about 10/255).
65+focus.animation = pulse
66+focus.effect = screen
67+focus.strength = 0.04
6268
6369 # Named workspaces are outside the numbered list and require a bind. The bind
6470 # shows one; pressing it again returns to the previous workspace.
@@ -59,6 +59,12 @@ touchpad.scroll-factor = 1
59 59
60 # on: moving a column or window to another workspace shows that workspace.60 # on: moving a column or window to another workspace shows that workspace.
61 focus.follow-move = off61 focus.follow-move = off
62+# Focus indicator on the newly focused window: an animation (pulse or off)
63+# of an effect (screen: a slight brightening), peaking at strength
64+# (0.01 to 0.2; 0.04: black reaches about 10/255).
65+focus.animation = pulse
66+focus.effect = screen
67+focus.strength = 0.04
62 68
63 # Named workspaces are outside the numbered list and require a bind. The bind69 # Named workspaces are outside the numbered list and require a bind. The bind
64 # shows one; pressing it again returns to the previous workspace.70 # shows one; pressing it again returns to the previous workspace.
M internal/adapters/config/config.go +20 -0
@@ -122,6 +122,9 @@ func Defaults() ports.Config {
122122 c.Touchpad.Tap = true
123123 c.Touchpad.AccelProfile = ports.AccelAdaptive
124124 c.Touchpad.ScrollFactor = 1
125+ c.Focus.Animation = ports.FocusAnimationPulse
126+ c.Focus.Effect = ports.FocusEffectScreen
127+ c.Focus.Strength = 0.04
125128 c.Binds = map[string]string{}
126129 for _, b := range defaultBinds {
127130 combos := []string{b.combo}
@@ -585,6 +588,23 @@ func set(c *ports.Config, key, v string) error {
585588 return err
586589 }
587590 c.Focus.FollowMove = b
591+ case "focus.animation":
592+ if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {
593+ return fmt.Errorf("must be pulse or off")
594+ }
595+ c.Focus.Animation = v
596+ case "focus.effect":
597+ if v != ports.FocusEffectScreen {
598+ return fmt.Errorf("must be screen")
599+ }
600+ c.Focus.Effect = v
601+ case "focus.strength":
602+ // Capped low: the focus indicator must never flash.
603+ f, err := strconv.ParseFloat(v, 64)
604+ if err != nil || !(f >= 0.01 && f <= 0.2) {
605+ return fmt.Errorf("must be between 0.01 and 0.2")
606+ }
607+ c.Focus.Strength = f
588608 case "render.direct-scanout":
589609 b, err := onOff(v)
590610 if err != nil {
@@ -122,6 +122,9 @@ func Defaults() ports.Config {
122 c.Touchpad.Tap = true122 c.Touchpad.Tap = true
123 c.Touchpad.AccelProfile = ports.AccelAdaptive123 c.Touchpad.AccelProfile = ports.AccelAdaptive
124 c.Touchpad.ScrollFactor = 1124 c.Touchpad.ScrollFactor = 1
125+ c.Focus.Animation = ports.FocusAnimationPulse
126+ c.Focus.Effect = ports.FocusEffectScreen
127+ c.Focus.Strength = 0.04
125 c.Binds = map[string]string{}128 c.Binds = map[string]string{}
126 for _, b := range defaultBinds {129 for _, b := range defaultBinds {
127 combos := []string{b.combo}130 combos := []string{b.combo}
@@ -585,6 +588,23 @@ func set(c *ports.Config, key, v string) error {
585 return err588 return err
586 }589 }
587 c.Focus.FollowMove = b590 c.Focus.FollowMove = b
591+ case "focus.animation":
592+ if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {
593+ return fmt.Errorf("must be pulse or off")
594+ }
595+ c.Focus.Animation = v
596+ case "focus.effect":
597+ if v != ports.FocusEffectScreen {
598+ return fmt.Errorf("must be screen")
599+ }
600+ c.Focus.Effect = v
601+ case "focus.strength":
602+ // Capped low: the focus indicator must never flash.
603+ f, err := strconv.ParseFloat(v, 64)
604+ if err != nil || !(f >= 0.01 && f <= 0.2) {
605+ return fmt.Errorf("must be between 0.01 and 0.2")
606+ }
607+ c.Focus.Strength = f
588 case "render.direct-scanout":608 case "render.direct-scanout":
589 b, err := onOff(v)609 b, err := onOff(v)
590 if err != nil {610 if err != nil {
M internal/adapters/config/config_test.go +18 -0
@@ -77,6 +77,24 @@ func TestFollowMove(t *testing.T) {
7777 }
7878 }
7979
80+func TestFocusIndicator(t *testing.T) {
81+ d := Defaults().Focus
82+ if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
83+ t.Fatal("focus indicator defaults", d)
84+ }
85+ c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
86+ if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
87+ t.Fatal(c.Focus, w)
88+ }
89+ // Each rejected value warns and keeps its default.
90+ for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
91+ c, w = parseString(t, line+"\n")
92+ if c.Focus != d || len(w) != 1 {
93+ t.Fatal(line, c.Focus, w)
94+ }
95+ }
96+}
97+
8098 func TestTouchpadNaturalScroll(t *testing.T) {
8199 if Defaults().Touchpad.NaturalScroll {
82100 t.Fatal("natural scroll on by default")
@@ -77,6 +77,24 @@ func TestFollowMove(t *testing.T) {
77 }77 }
78 }78 }
79 79
80+func TestFocusIndicator(t *testing.T) {
81+ d := Defaults().Focus
82+ if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
83+ t.Fatal("focus indicator defaults", d)
84+ }
85+ c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
86+ if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
87+ t.Fatal(c.Focus, w)
88+ }
89+ // Each rejected value warns and keeps its default.
90+ for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
91+ c, w = parseString(t, line+"\n")
92+ if c.Focus != d || len(w) != 1 {
93+ t.Fatal(line, c.Focus, w)
94+ }
95+ }
96+}
97+
80 func TestTouchpadNaturalScroll(t *testing.T) {98 func TestTouchpadNaturalScroll(t *testing.T) {
81 if Defaults().Touchpad.NaturalScroll {99 if Defaults().Touchpad.NaturalScroll {
82 t.Fatal("natural scroll on by default")100 t.Fatal("natural scroll on by default")
M internal/adapters/drm/overlay.go +4 -3
@@ -75,9 +75,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7575 c := surfaces[w.ID]
7676 // A viewport crop or PQ content is composed: the plane would show
7777 // the whole buffer, and raw PQ values on this SDR output. A dimmed
78- // window (a peeking stashed one) needs the veil drawn over it; an
79- // overview preview is drawn smaller than its buffer.
80- if w.Dim <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {
78+ // window (a peeking stashed one) needs the veil drawn over it, and
79+ // one with the focus effect needs its lift; an overview preview is
80+ // drawn smaller than its buffer.
81+ if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {
8182 pick = w
8283 continue
8384 }
@@ -75,9 +75,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
75 c := surfaces[w.ID]75 c := surfaces[w.ID]
76 // A viewport crop or PQ content is composed: the plane would show76 // A viewport crop or PQ content is composed: the plane would show
77 // the whole buffer, and raw PQ values on this SDR output. A dimmed77 // the whole buffer, and raw PQ values on this SDR output. A dimmed
78- // window (a peeking stashed one) needs the veil drawn over it; an78+ // window (a peeking stashed one) needs the veil drawn over it, and
79- // overview preview is drawn smaller than its buffer.79+ // one with the focus effect needs its lift; an overview preview is
80- if w.Dim <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {80+ // drawn smaller than its buffer.
81+ if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {
81 pick = w82 pick = w
82 continue83 continue
83 }84 }
M internal/adapters/drm/overlay_test.go +6 -0
@@ -107,6 +107,12 @@ func TestOverlayCandidate(t *testing.T) {
107107 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
108108 t.Fatalf("dimmed window reason %q", reason)
109109 }
110+ // A window with the focus effect needs it composed.
111+ s, _ = overlayScene()
112+ s.Windows[len(s.Windows)-1].FocusEffect = 0.05
113+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
114+ t.Fatalf("pulsing window reason %q", reason)
115+ }
110116 // An overview preview is drawn smaller than its buffer: composed.
111117 s, _ = overlayScene()
112118 s.Windows[len(s.Windows)-1].Preview = 0.5
@@ -107,6 +107,12 @@ func TestOverlayCandidate(t *testing.T) {
107 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {107 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
108 t.Fatalf("dimmed window reason %q", reason)108 t.Fatalf("dimmed window reason %q", reason)
109 }109 }
110+ // A window with the focus effect needs it composed.
111+ s, _ = overlayScene()
112+ s.Windows[len(s.Windows)-1].FocusEffect = 0.05
113+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
114+ t.Fatalf("pulsing window reason %q", reason)
115+ }
110 // An overview preview is drawn smaller than its buffer: composed.116 // An overview preview is drawn smaller than its buffer: composed.
111 s, _ = overlayScene()117 s, _ = overlayScene()
112 s.Windows[len(s.Windows)-1].Preview = 0.5118 s.Windows[len(s.Windows)-1].Preview = 0.5
M internal/adapters/vulkan/hdr_composition_test.go +19 -0
@@ -172,6 +172,25 @@ func TestHDRLinearBlend(t *testing.T) {
172172 }
173173 }
174174
175+// The focus pulse on an HDR output applies the SDR lift to sRGB content
176+// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
177+// separates this from a lift in linear light or none at all.
178+func TestHDRPulse(t *testing.T) {
179+ r := hdrTestRenderer(t)
180+ c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
181+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
182+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
183+ t.Fatal(err)
184+ }
185+ got := hdrTargetAt(t, r, 5, 5)
186+ want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
187+ for i, v := range got {
188+ if math.Abs(v-want) > .006 {
189+ t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
190+ }
191+ }
192+}
193+
175194 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
176195 r := hdrTestRenderer(t)
177196 const background = "#804020"
@@ -172,6 +172,25 @@ func TestHDRLinearBlend(t *testing.T) {
172 }172 }
173 }173 }
174 174
175+// The focus pulse on an HDR output applies the SDR lift to sRGB content
176+// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
177+// separates this from a lift in linear light or none at all.
178+func TestHDRPulse(t *testing.T) {
179+ r := hdrTestRenderer(t)
180+ c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
181+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
182+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
183+ t.Fatal(err)
184+ }
185+ got := hdrTargetAt(t, r, 5, 5)
186+ want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
187+ for i, v := range got {
188+ if math.Abs(v-want) > .006 {
189+ t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
190+ }
191+ }
192+}
193+
175 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {194 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
176 r := hdrTestRenderer(t)195 r := hdrTestRenderer(t)
177 const background = "#804020"196 const background = "#804020"
M internal/adapters/vulkan/renderer_test.go +24 -0
@@ -111,6 +111,30 @@ func TestRendererFade(t *testing.T) {
111111 }
112112 }
113113
114+// A pulsing window lifts toward white: black the most, white not at all.
115+func TestRendererPulse(t *testing.T) {
116+ r, err := New(64, 48)
117+ if err != nil {
118+ t.Skipf("Vulkan unavailable: %v", err)
119+ }
120+ defer r.Close()
121+ for i, tc := range []struct {
122+ in, want uint8
123+ }{{0, 26}, {64, 83}, {200, 206}, {255, 255}} {
124+ // One window per case: the shm copy is cached per window.
125+ id := ports.WindowID(i + 1)
126+ c := solidContent(t, 64, 48, color.RGBA{tc.in, tc.in, tc.in, 255})
127+ s := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: id, Rect: ports.Rect{W: 64, H: 48}, FocusEffect: 0.1}}}
128+ if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{id: c}); err != nil {
129+ t.Fatal(err)
130+ }
131+ got := readPixels(t, r).At(10, 10).(color.RGBA)
132+ if d := int(got.R) - int(tc.want); d < -1 || d > 1 || got.R != got.G || got.G != got.B {
133+ t.Errorf("pulse of %d = %v, want %d", tc.in, got, tc.want)
134+ }
135+ }
136+}
137+
114138 func TestRendererContents(t *testing.T) {
115139 r, err := New(64, 48)
116140 if err != nil {
@@ -111,6 +111,30 @@ func TestRendererFade(t *testing.T) {
111 }111 }
112 }112 }
113 113
114+// A pulsing window lifts toward white: black the most, white not at all.
115+func TestRendererPulse(t *testing.T) {
116+ r, err := New(64, 48)
117+ if err != nil {
118+ t.Skipf("Vulkan unavailable: %v", err)
119+ }
120+ defer r.Close()
121+ for i, tc := range []struct {
122+ in, want uint8
123+ }{{0, 26}, {64, 83}, {200, 206}, {255, 255}} {
124+ // One window per case: the shm copy is cached per window.
125+ id := ports.WindowID(i + 1)
126+ c := solidContent(t, 64, 48, color.RGBA{tc.in, tc.in, tc.in, 255})
127+ s := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: id, Rect: ports.Rect{W: 64, H: 48}, FocusEffect: 0.1}}}
128+ if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{id: c}); err != nil {
129+ t.Fatal(err)
130+ }
131+ got := readPixels(t, r).At(10, 10).(color.RGBA)
132+ if d := int(got.R) - int(tc.want); d < -1 || d > 1 || got.R != got.G || got.G != got.B {
133+ t.Errorf("pulse of %d = %v, want %d", tc.in, got, tc.want)
134+ }
135+ }
136+}
137+
114 func TestRendererContents(t *testing.T) {138 func TestRendererContents(t *testing.T) {
115 r, err := New(64, 48)139 r, err := New(64, 48)
116 if err != nil {140 if err != nil {
M internal/adapters/vulkan/scene.go +8 -2
@@ -19,7 +19,10 @@ type sceneWalk struct {
1919 scale float64
2020 // zoom shrinks the surfaces of the window being placed: an overview
2121 // preview (ports.SceneWindow.Preview); 1 otherwise.
22- zoom float64
22+ zoom float64
23+ // pulse is the focus effect of the window being placed
24+ // (ports.SceneWindow.FocusEffect); 0 otherwise.
25+ pulse float32
2326 bounds image.Rectangle
2427 draws []draw
2528 }
@@ -186,8 +189,9 @@ func (w *sceneWalk) windows() {
186189 if win.Preview > 0 {
187190 w.zoom = win.Preview
188191 }
192+ w.pulse = float32(max(0, min(win.FocusEffect, 1)))
189193 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
190- w.zoom = 1
194+ w.zoom, w.pulse = 1, 0
191195 }
192196 if win.Floating {
193197 w.separators(win.ID)
@@ -360,6 +364,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
360364 }
361365 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
362366 r.setContentColor(&dr, content)
367+ dr.pc.mapy[2] = w.pulse
363368 if im.yuv {
364369 dr.pc.misc[1] |= flagYUV
365370 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
@@ -396,6 +401,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
396401 }
397402 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
398403 r.setContentColor(&dr, content)
404+ dr.pc.mapy[2] = w.pulse
399405 dr.set = c.set
400406 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
401407 w.draws = append(w.draws, dr)
@@ -19,7 +19,10 @@ type sceneWalk struct {
19 scale float6419 scale float64
20 // zoom shrinks the surfaces of the window being placed: an overview20 // zoom shrinks the surfaces of the window being placed: an overview
21 // preview (ports.SceneWindow.Preview); 1 otherwise.21 // preview (ports.SceneWindow.Preview); 1 otherwise.
22- zoom float6422+ zoom float64
23+ // pulse is the focus effect of the window being placed
24+ // (ports.SceneWindow.FocusEffect); 0 otherwise.
25+ pulse float32
23 bounds image.Rectangle26 bounds image.Rectangle
24 draws []draw27 draws []draw
25 }28 }
@@ -186,8 +189,9 @@ func (w *sceneWalk) windows() {
186 if win.Preview > 0 {189 if win.Preview > 0 {
187 w.zoom = win.Preview190 w.zoom = win.Preview
188 }191 }
192+ w.pulse = float32(max(0, min(win.FocusEffect, 1)))
189 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)193 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
190- w.zoom = 1194+ w.zoom, w.pulse = 1, 0
191 }195 }
192 if win.Floating {196 if win.Floating {
193 w.separators(win.ID)197 w.separators(win.ID)
@@ -360,6 +364,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
360 }364 }
361 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)365 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
362 r.setContentColor(&dr, content)366 r.setContentColor(&dr, content)
367+ dr.pc.mapy[2] = w.pulse
363 if im.yuv {368 if im.yuv {
364 dr.pc.misc[1] |= flagYUV369 dr.pc.misc[1] |= flagYUV
365 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {370 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
@@ -396,6 +401,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
396 }401 }
397 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)402 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
398 r.setContentColor(&dr, content)403 r.setContentColor(&dr, content)
404+ dr.pc.mapy[2] = w.pulse
399 dr.set = c.set405 dr.set = c.set
400 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}406 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
401 w.draws = append(w.draws, dr)407 w.draws = append(w.draws, dr)
M internal/adapters/vulkan/shaders/compose.frag +6 -0
@@ -132,6 +132,12 @@ void main() {
132132 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
133133 }
134134 }
135+ if (d.mapy.z > 0.0) {
136+ // Focus pulse: a screen-like lift toward white, on premultiplied
137+ // values (rgb/a + k * (1 - rgb/a)) * a. Black shows it most; white
138+ // never saturates.
139+ c.rgb += d.mapy.z * (c.a - c.rgb);
140+ }
135141 if (d.misc.x != modeSolid) {
136142 // The surface's alpha modifier; premultiplied, it scales all four.
137143 c *= d.color.a;
@@ -132,6 +132,12 @@ void main() {
132 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;132 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
133 }133 }
134 }134 }
135+ if (d.mapy.z > 0.0) {
136+ // Focus pulse: a screen-like lift toward white, on premultiplied
137+ // values (rgb/a + k * (1 - rgb/a)) * a. Black shows it most; white
138+ // never saturates.
139+ c.rgb += d.mapy.z * (c.a - c.rgb);
140+ }
135 if (d.misc.x != modeSolid) {141 if (d.misc.x != modeSolid) {
136 // The surface's alpha modifier; premultiplied, it scales all four.142 // The surface's alpha modifier; premultiplied, it scales all four.
137 c *= d.color.a;143 c *= d.color.a;
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose.vert +1 -1
@@ -4,7 +4,7 @@
44 layout(push_constant) uniform Draw {
55 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
66 vec4 map; // source texel = map.xy + fragment centre.x * map.zw
7- vec4 mapy; // + fragment centre.y * mapy.xy (zw unused): any buffer transform
7+ vec4 mapy; // + fragment centre.y * mapy.xy: any buffer transform; mapy.z focus pulse
88 vec4 color; // solid fills: premultiplied RGBA
99 vec4 crop; // source crop bounds in buffer pixels
1010 uvec4 buf; // buffer draws: first word, row width, height, unused
@@ -4,7 +4,7 @@
4 layout(push_constant) uniform Draw {4 layout(push_constant) uniform Draw {
5 ivec4 rect; // visible x0, y0, x1, y1 in target pixels5 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
6 vec4 map; // source texel = map.xy + fragment centre.x * map.zw6 vec4 map; // source texel = map.xy + fragment centre.x * map.zw
7- vec4 mapy; // + fragment centre.y * mapy.xy (zw unused): any buffer transform7+ vec4 mapy; // + fragment centre.y * mapy.xy: any buffer transform; mapy.z focus pulse
8 vec4 color; // solid fills: premultiplied RGBA8 vec4 color; // solid fills: premultiplied RGBA
9 vec4 crop; // source crop bounds in buffer pixels9 vec4 crop; // source crop bounds in buffer pixels
10 uvec4 buf; // buffer draws: first word, row width, height, unused10 uvec4 buf; // buffer draws: first word, row width, height, unused
M internal/adapters/vulkan/shaders/compose_hdr.frag +9 -2
@@ -126,8 +126,15 @@ void main() {
126126 if (c.a > 0.0 && d.misc.x != modeSolid) {
127127 // ext_linear has the protocol's default 80 cd/m² reference white.
128128 // Keep negative and above-white values in fp16 until the HDR pass.
129- vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(c.rgb / c.a, d.color.x) :
130- (d.misc.y & flagExtendedLinear) != 0u ? c.rgb / c.a * (80.0 / d.color.x) : decodeSRGB(c.rgb / c.a);
129+ vec3 v = c.rgb / c.a;
130+ bool srgb = (d.misc.y & (flagPQ | flagExtendedLinear)) == 0u;
131+ if (srgb && d.mapy.z > 0.0) {
132+ // Focus pulse: the SDR lift, on sRGB-encoded values before
133+ // decoding, so it looks the same as on an SDR output.
134+ v += d.mapy.z * (1.0 - v);
135+ }
136+ vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :
137+ (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);
131138 c.rgb = linearRGB * c.a;
132139 }
133140 if (d.misc.x != modeSolid) {
@@ -126,8 +126,15 @@ void main() {
126 if (c.a > 0.0 && d.misc.x != modeSolid) {126 if (c.a > 0.0 && d.misc.x != modeSolid) {
127 // ext_linear has the protocol's default 80 cd/m² reference white.127 // ext_linear has the protocol's default 80 cd/m² reference white.
128 // Keep negative and above-white values in fp16 until the HDR pass.128 // Keep negative and above-white values in fp16 until the HDR pass.
129- vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(c.rgb / c.a, d.color.x) :129+ vec3 v = c.rgb / c.a;
130- (d.misc.y & flagExtendedLinear) != 0u ? c.rgb / c.a * (80.0 / d.color.x) : decodeSRGB(c.rgb / c.a);130+ bool srgb = (d.misc.y & (flagPQ | flagExtendedLinear)) == 0u;
131+ if (srgb && d.mapy.z > 0.0) {
132+ // Focus pulse: the SDR lift, on sRGB-encoded values before
133+ // decoding, so it looks the same as on an SDR output.
134+ v += d.mapy.z * (1.0 - v);
135+ }
136+ vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :
137+ (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);
131 c.rgb = linearRGB * c.a;138 c.rgb = linearRGB * c.a;
132 }139 }
133 if (d.misc.x != modeSolid) {140 if (d.misc.x != modeSolid) {
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/app/app.go +4 -0
@@ -473,6 +473,10 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
473473 rects := make([]string, 0, len(s.Windows))
474474 for _, w := range s.Windows {
475475 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
476+ if w.FocusEffect > 0 {
477+ // The focus indicator frame by frame: window and effect.
478+ ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
479+ }
476480 }
477481 ev.Strs("windows", rects).Msg("scene")
478482 }
@@ -473,6 +473,10 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
473 rects := make([]string, 0, len(s.Windows))473 rects := make([]string, 0, len(s.Windows))
474 for _, w := range s.Windows {474 for _, w := range s.Windows {
475 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))475 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
476+ if w.FocusEffect > 0 {
477+ // The focus indicator frame by frame: window and effect.
478+ ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
479+ }
476 }480 }
477 ev.Strs("windows", rects).Msg("scene")481 ev.Strs("windows", rects).Msg("scene")
478 }482 }
M internal/app/app_test.go +2 -0
@@ -222,6 +222,8 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
222222 t.Setenv("XDG_STATE_HOME", t.TempDir())
223223 cfg := config.Defaults()
224224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
225+ // Pulse frames would fill the scene tap before the click.
226+ cfg.Focus.Animation = ports.FocusAnimationOff
225227 scenes := make(chan []ports.Scene, 128)
226228 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
227229 if err != nil {
@@ -222,6 +222,8 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
222 t.Setenv("XDG_STATE_HOME", t.TempDir())222 t.Setenv("XDG_STATE_HOME", t.TempDir())
223 cfg := config.Defaults()223 cfg := config.Defaults()
224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
225+ // Pulse frames would fill the scene tap before the click.
226+ cfg.Focus.Animation = ports.FocusAnimationOff
225 scenes := make(chan []ports.Scene, 128)227 scenes := make(chan []ports.Scene, 128)
226 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})228 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
227 if err != nil {229 if err != nil {
M internal/core/capindicator.go +1 -8
@@ -126,14 +126,7 @@ func (s *captureState) armTimer(now time.Time, clock ports.Clock) {
126126 first = f.until
127127 }
128128 }
129- d := max(first.Sub(now), time.Millisecond)
130- if clock != nil {
131- t := clock.NewTimer(d)
132- s.timerC, s.timerStop = t.C(), t.Stop
133- return
134- }
135- t := time.NewTimer(d)
136- s.timerC, s.timerStop = t.C, t.Stop
129+ s.timerC, s.timerStop = newTimer(clock, max(first.Sub(now), time.Millisecond))
137130 }
138131
139132 func (s *captureState) stopTimer() {
@@ -126,14 +126,7 @@ func (s *captureState) armTimer(now time.Time, clock ports.Clock) {
126 first = f.until126 first = f.until
127 }127 }
128 }128 }
129- d := max(first.Sub(now), time.Millisecond)129+ s.timerC, s.timerStop = newTimer(clock, max(first.Sub(now), time.Millisecond))
130- if clock != nil {
131- t := clock.NewTimer(d)
132- s.timerC, s.timerStop = t.C(), t.Stop
133- return
134- }
135- t := time.NewTimer(d)
136- s.timerC, s.timerStop = t.C, t.Stop
137 }130 }
138 131
139 func (s *captureState) stopTimer() {132 func (s *captureState) stopTimer() {
M internal/core/core.go +40 -0
@@ -133,6 +133,8 @@ type Core struct {
133133 pointerAt [2]float64
134134 // capt is the capture state (capture.go, capindicator.go).
135135 capt captureState
136+ // pulse marks a window that just got the focus (pulse.go).
137+ pulse focusPulse
136138 }
137139
138140 func keyName(s string) string {
@@ -347,6 +349,17 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
347349 }
348350 return c, nil
349351 }
352+
353+// newTimer makes a timer on clock, or the system timer when clock is nil.
354+func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {
355+ if clock != nil {
356+ t := clock.NewTimer(d)
357+ return t.C(), t.Stop
358+ }
359+ t := time.NewTimer(d)
360+ return t.C, t.Stop
361+}
362+
350363 func (c *Core) now() time.Time {
351364 if c.ch.Clock != nil {
352365 return c.ch.Clock.Now()
@@ -512,6 +525,14 @@ func (c *Core) publish(ctx context.Context) error {
512525 return err
513526 }
514527 focus := c.keyboardFocus()
528+ window, _ := c.cur().mon.Focused()
529+ c.pulseFocus(window, focus == window)
530+ var pulse float64
531+ if c.pulse.id != 0 {
532+ pulse = c.advancePulse(c.now())
533+ }
534+ // drawable: the scene can show a pulse on the focused window.
535+ drawable := false
515536 scenes := make([]ports.Scene, 0, len(c.screens))
516537 // Before the first output (and after the last is unplugged) the
517538 // placeholder's scene has no output name: no renderer draws it.
@@ -554,6 +575,12 @@ func (c *Core) publish(ctx context.Context) error {
554575 if p.Peek {
555576 sw.Dim = c.cfg.Stash.Dim
556577 }
578+ if focused && p.ID == c.pulse.target && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
579+ drawable = true
580+ if p.ID == c.pulse.id {
581+ sw.FocusEffect = pulse
582+ }
583+ }
557584 scene.Windows = append(scene.Windows, sw)
558585 t := configureTarget{output: sc.name(), area: frame, focused: focused}
559586 if !p.Hidden && p.Preview == 0 {
@@ -581,6 +608,10 @@ func (c *Core) publish(ctx context.Context) error {
581608 scenes = append(scenes, scene)
582609 }
583610 c.configures.prune()
611+ // A pulse nothing shows (fullscreen, overview) asks for no frames.
612+ if c.pulse.drawable = drawable; !drawable {
613+ c.pulse.id = 0
614+ }
584615 // A workspace switch can hide the window under the pointer; it must not get
585616 // clicks. The next motion re-runs hit-testing.
586617 if c.pointer != 0 && !c.visible(c.pointer) {
@@ -917,6 +948,7 @@ func (c *Core) Run(ctx context.Context) error {
917948 c.syncSecurity()
918949 defer c.stopFrame()
919950 defer c.stopCaptureTimer()
951+ defer c.stopPulseTimer()
920952 // Startup commands run once per session. Full launcher queues retain a
921953 // bounded remainder, selectable alongside owner events without polling.
922954 c.startup = make([][]string, len(c.cfg.Startup))
@@ -1104,6 +1136,14 @@ func (c *Core) Run(ctx context.Context) error {
11041136 return nil
11051137 }
11061138 continue
1139+ case <-c.pulse.timerC:
1140+ if c.securityCheckpoint(ctx) != nil {
1141+ return nil
1142+ }
1143+ if c.pulseTick() && c.publish(ctx) != nil {
1144+ return nil
1145+ }
1146+ continue
11071147 case <-c.capt.timerC:
11081148 c.capt.timerC, c.capt.timerStop = nil, nil
11091149 if c.captureFlashTick() && c.publish(ctx) != nil {
@@ -133,6 +133,8 @@ type Core struct {
133 pointerAt [2]float64133 pointerAt [2]float64
134 // capt is the capture state (capture.go, capindicator.go).134 // capt is the capture state (capture.go, capindicator.go).
135 capt captureState135 capt captureState
136+ // pulse marks a window that just got the focus (pulse.go).
137+ pulse focusPulse
136 }138 }
137 139
138 func keyName(s string) string {140 func keyName(s string) string {
@@ -347,6 +349,17 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
347 }349 }
348 return c, nil350 return c, nil
349 }351 }
352+
353+// newTimer makes a timer on clock, or the system timer when clock is nil.
354+func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {
355+ if clock != nil {
356+ t := clock.NewTimer(d)
357+ return t.C(), t.Stop
358+ }
359+ t := time.NewTimer(d)
360+ return t.C, t.Stop
361+}
362+
350 func (c *Core) now() time.Time {363 func (c *Core) now() time.Time {
351 if c.ch.Clock != nil {364 if c.ch.Clock != nil {
352 return c.ch.Clock.Now()365 return c.ch.Clock.Now()
@@ -512,6 +525,14 @@ func (c *Core) publish(ctx context.Context) error {
512 return err525 return err
513 }526 }
514 focus := c.keyboardFocus()527 focus := c.keyboardFocus()
528+ window, _ := c.cur().mon.Focused()
529+ c.pulseFocus(window, focus == window)
530+ var pulse float64
531+ if c.pulse.id != 0 {
532+ pulse = c.advancePulse(c.now())
533+ }
534+ // drawable: the scene can show a pulse on the focused window.
535+ drawable := false
515 scenes := make([]ports.Scene, 0, len(c.screens))536 scenes := make([]ports.Scene, 0, len(c.screens))
516 // Before the first output (and after the last is unplugged) the537 // Before the first output (and after the last is unplugged) the
517 // placeholder's scene has no output name: no renderer draws it.538 // placeholder's scene has no output name: no renderer draws it.
@@ -554,6 +575,12 @@ func (c *Core) publish(ctx context.Context) error {
554 if p.Peek {575 if p.Peek {
555 sw.Dim = c.cfg.Stash.Dim576 sw.Dim = c.cfg.Stash.Dim
556 }577 }
578+ if focused && p.ID == c.pulse.target && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
579+ drawable = true
580+ if p.ID == c.pulse.id {
581+ sw.FocusEffect = pulse
582+ }
583+ }
557 scene.Windows = append(scene.Windows, sw)584 scene.Windows = append(scene.Windows, sw)
558 t := configureTarget{output: sc.name(), area: frame, focused: focused}585 t := configureTarget{output: sc.name(), area: frame, focused: focused}
559 if !p.Hidden && p.Preview == 0 {586 if !p.Hidden && p.Preview == 0 {
@@ -581,6 +608,10 @@ func (c *Core) publish(ctx context.Context) error {
581 scenes = append(scenes, scene)608 scenes = append(scenes, scene)
582 }609 }
583 c.configures.prune()610 c.configures.prune()
611+ // A pulse nothing shows (fullscreen, overview) asks for no frames.
612+ if c.pulse.drawable = drawable; !drawable {
613+ c.pulse.id = 0
614+ }
584 // A workspace switch can hide the window under the pointer; it must not get615 // A workspace switch can hide the window under the pointer; it must not get
585 // clicks. The next motion re-runs hit-testing.616 // clicks. The next motion re-runs hit-testing.
586 if c.pointer != 0 && !c.visible(c.pointer) {617 if c.pointer != 0 && !c.visible(c.pointer) {
@@ -917,6 +948,7 @@ func (c *Core) Run(ctx context.Context) error {
917 c.syncSecurity()948 c.syncSecurity()
918 defer c.stopFrame()949 defer c.stopFrame()
919 defer c.stopCaptureTimer()950 defer c.stopCaptureTimer()
951+ defer c.stopPulseTimer()
920 // Startup commands run once per session. Full launcher queues retain a952 // Startup commands run once per session. Full launcher queues retain a
921 // bounded remainder, selectable alongside owner events without polling.953 // bounded remainder, selectable alongside owner events without polling.
922 c.startup = make([][]string, len(c.cfg.Startup))954 c.startup = make([][]string, len(c.cfg.Startup))
@@ -1104,6 +1136,14 @@ func (c *Core) Run(ctx context.Context) error {
1104 return nil1136 return nil
1105 }1137 }
1106 continue1138 continue
1139+ case <-c.pulse.timerC:
1140+ if c.securityCheckpoint(ctx) != nil {
1141+ return nil
1142+ }
1143+ if c.pulseTick() && c.publish(ctx) != nil {
1144+ return nil
1145+ }
1146+ continue
1107 case <-c.capt.timerC:1147 case <-c.capt.timerC:
1108 c.capt.timerC, c.capt.timerStop = nil, nil1148 c.capt.timerC, c.capt.timerStop = nil, nil
1109 if c.captureFlashTick() && c.publish(ctx) != nil {1149 if c.captureFlashTick() && c.publish(ctx) != nil {
M internal/core/core_test.go +2 -0
@@ -803,6 +803,7 @@ func TestFloatOverFullscreenHit(t *testing.T) {
803803 func TestFullscreenAtMapIgnored(t *testing.T) {
804804 cfg := config.Defaults()
805805 cfg.Layout.Overflow = "fixed"
806+ cfg.Focus.Animation = ports.FocusAnimationOff
806807 client := make(chan ports.ClientEvent, 8)
807808 output := make(chan ports.OutputEvent, 8)
808809 commands := make(chan ports.ClientCommand, 64)
@@ -882,6 +883,7 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) {
882883 // A taskbar's fullscreen request is the user's: it applies at once.
883884 func TestExternalFullscreenAtMapApplies(t *testing.T) {
884885 cfg := config.Defaults()
886+ cfg.Focus.Animation = ports.FocusAnimationOff
885887 client := make(chan ports.ClientEvent, 8)
886888 output := make(chan ports.OutputEvent, 8)
887889 commands := make(chan ports.ClientCommand, 64)
@@ -803,6 +803,7 @@ func TestFloatOverFullscreenHit(t *testing.T) {
803 func TestFullscreenAtMapIgnored(t *testing.T) {803 func TestFullscreenAtMapIgnored(t *testing.T) {
804 cfg := config.Defaults()804 cfg := config.Defaults()
805 cfg.Layout.Overflow = "fixed"805 cfg.Layout.Overflow = "fixed"
806+ cfg.Focus.Animation = ports.FocusAnimationOff
806 client := make(chan ports.ClientEvent, 8)807 client := make(chan ports.ClientEvent, 8)
807 output := make(chan ports.OutputEvent, 8)808 output := make(chan ports.OutputEvent, 8)
808 commands := make(chan ports.ClientCommand, 64)809 commands := make(chan ports.ClientCommand, 64)
@@ -882,6 +883,7 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) {
882 // A taskbar's fullscreen request is the user's: it applies at once.883 // A taskbar's fullscreen request is the user's: it applies at once.
883 func TestExternalFullscreenAtMapApplies(t *testing.T) {884 func TestExternalFullscreenAtMapApplies(t *testing.T) {
884 cfg := config.Defaults()885 cfg := config.Defaults()
886+ cfg.Focus.Animation = ports.FocusAnimationOff
885 client := make(chan ports.ClientEvent, 8)887 client := make(chan ports.ClientEvent, 8)
886 output := make(chan ports.OutputEvent, 8)888 output := make(chan ports.OutputEvent, 8)
887 commands := make(chan ports.ClientCommand, 64)889 commands := make(chan ports.ClientCommand, 64)
M internal/core/gesture.go +7 -11
@@ -343,11 +343,14 @@ func (c *Core) animate(now time.Time) {
343343 // sliding reports whether a spring runs on the named output.
344344 func (c *Core) sliding(output string) bool {
345345 i := c.screenIndex(output)
346- return i >= 0 && c.screens[i].mon.springing()
346+ return i >= 0 && (c.screens[i].mon.springing() || c.pulsing(c.screens[i]))
347347 }
348348
349-// animating reports whether a spring runs on any output.
349+// animating reports whether a spring or the focus pulse runs on any output.
350350 func (c *Core) animating() bool {
351+ if c.pulse.id != 0 {
352+ return true
353+ }
351354 for _, sc := range c.screens {
352355 if sc.mon.springing() {
353356 return true
@@ -373,7 +376,7 @@ func (m *Monitor) springing() bool {
373376 func (c *Core) frameFallback() time.Duration {
374377 refresh := 60000
375378 for _, sc := range c.screens {
376- if r := sc.info.RefreshMilli; r > 0 && sc.mon.springing() {
379+ if r := sc.info.RefreshMilli; r > 0 && (sc.mon.springing() || c.pulsing(sc)) {
377380 refresh = min(refresh, r)
378381 }
379382 }
@@ -383,14 +386,7 @@ func (c *Core) frameFallback() time.Duration {
383386 // armFrame starts the fallback timer of a running slide.
384387 func (c *Core) armFrame() {
385388 c.stopFrame()
386- d := c.frameFallback()
387- if c.ch.Clock != nil {
388- t := c.ch.Clock.NewTimer(d)
389- c.frameC, c.frameStop = t.C(), t.Stop
390- return
391- }
392- t := time.NewTimer(d)
393- c.frameC, c.frameStop = t.C, t.Stop
389+ c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback())
394390 }
395391
396392 func (c *Core) stopFrame() {
@@ -343,11 +343,14 @@ func (c *Core) animate(now time.Time) {
343 // sliding reports whether a spring runs on the named output.343 // sliding reports whether a spring runs on the named output.
344 func (c *Core) sliding(output string) bool {344 func (c *Core) sliding(output string) bool {
345 i := c.screenIndex(output)345 i := c.screenIndex(output)
346- return i >= 0 && c.screens[i].mon.springing()346+ return i >= 0 && (c.screens[i].mon.springing() || c.pulsing(c.screens[i]))
347 }347 }
348 348
349-// animating reports whether a spring runs on any output.349+// animating reports whether a spring or the focus pulse runs on any output.
350 func (c *Core) animating() bool {350 func (c *Core) animating() bool {
351+ if c.pulse.id != 0 {
352+ return true
353+ }
351 for _, sc := range c.screens {354 for _, sc := range c.screens {
352 if sc.mon.springing() {355 if sc.mon.springing() {
353 return true356 return true
@@ -373,7 +376,7 @@ func (m *Monitor) springing() bool {
373 func (c *Core) frameFallback() time.Duration {376 func (c *Core) frameFallback() time.Duration {
374 refresh := 60000377 refresh := 60000
375 for _, sc := range c.screens {378 for _, sc := range c.screens {
376- if r := sc.info.RefreshMilli; r > 0 && sc.mon.springing() {379+ if r := sc.info.RefreshMilli; r > 0 && (sc.mon.springing() || c.pulsing(sc)) {
377 refresh = min(refresh, r)380 refresh = min(refresh, r)
378 }381 }
379 }382 }
@@ -383,14 +386,7 @@ func (c *Core) frameFallback() time.Duration {
383 // armFrame starts the fallback timer of a running slide.386 // armFrame starts the fallback timer of a running slide.
384 func (c *Core) armFrame() {387 func (c *Core) armFrame() {
385 c.stopFrame()388 c.stopFrame()
386- d := c.frameFallback()389+ c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback())
387- if c.ch.Clock != nil {
388- t := c.ch.Clock.NewTimer(d)
389- c.frameC, c.frameStop = t.C(), t.Stop
390- return
391- }
392- t := time.NewTimer(d)
393- c.frameC, c.frameStop = t.C, t.Stop
394 }390 }
395 391
396 func (c *Core) stopFrame() {392 func (c *Core) stopFrame() {
M internal/core/persist_test.go +8 -0
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/neferwl/internal/core"
1111 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1212 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
1314 )
1415
1516 // steppingClock returns a clock that moves a second forward on each Now:
@@ -22,6 +23,13 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
2223 now = now.Add(time.Second)
2324 return now
2425 }).Maybe()
26+ // Timers (the focus pulse) never fire: tests drive events only.
27+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
28+ timer := portsmocks.NewMockTimer(t)
29+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30+ timer.EXPECT().Stop().Return(true).Maybe()
31+ return timer
32+ }).Maybe()
2533 return clock
2634 }
2735
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/neferwl/internal/core"10 "github.com/bnema/neferwl/internal/core"
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
13 )14 )
14 15
15 // steppingClock returns a clock that moves a second forward on each Now:16 // steppingClock returns a clock that moves a second forward on each Now:
@@ -22,6 +23,13 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
22 now = now.Add(time.Second)23 now = now.Add(time.Second)
23 return now24 return now
24 }).Maybe()25 }).Maybe()
26+ // Timers (the focus pulse) never fire: tests drive events only.
27+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
28+ timer := portsmocks.NewMockTimer(t)
29+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30+ timer.EXPECT().Stop().Return(true).Maybe()
31+ return timer
32+ }).Maybe()
25 return clock33 return clock
26 }34 }
27 35
A internal/core/pulse.go +143 -0
@@ -0,0 +1,143 @@
1+package core
2+
3+import (
4+ "math"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// The focus pulse is the pulse animation of the focus indicator
11+// (focus.animation): it briefly shows the indicator's effect (focus.effect, a
12+// light screen blend visible on black backgrounds too) on a window that just
13+// got the keyboard focus. Only the last focus counts: it must hold for
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
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.
19+
20+const (
21+ pulseSettle = 150 * time.Millisecond
22+ pulseRise = 90 * time.Millisecond
23+ pulseFall = 230 * time.Millisecond
24+ pulseCooldown = time.Second
25+)
26+
27+// focusPulse is the pulse state. target is the window focus last seen and
28+// since when; id is the window pulsing from start (0: none). last and lastAt
29+// are the previous pulse, for the cooldown. drawable is whether the last
30+// scene could show a pulse on target.
31+type focusPulse struct {
32+ target WindowID
33+ since time.Time
34+ id WindowID
35+ start time.Time
36+ last WindowID
37+ lastAt time.Time
38+ drawable bool
39+ timerC <-chan time.Time
40+ timerStop func() bool
41+}
42+
43+func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse }
44+
45+// pulseFocus follows the window focus. held is false while something else
46+// (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
47+// stops, and the window getting it back is not a new focus. A new window
48+// stops the running pulse and waits pulseSettle before one starts. With the
49+// pulse off the focus is still followed, so turning it on pulses nothing.
50+func (c *Core) pulseFocus(window WindowID, held bool) {
51+ p := &c.pulse
52+ if !held || !c.pulseOn() {
53+ p.id = 0
54+ c.stopPulseTimer()
55+ if held {
56+ p.target = window
57+ }
58+ return
59+ }
60+ if window == p.target {
61+ return
62+ }
63+ p.target, p.id = window, 0
64+ c.stopPulseTimer()
65+ if window == 0 {
66+ return
67+ }
68+ p.since = c.now()
69+ if c.justMapped(window) {
70+ return
71+ }
72+ p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseSettle)
73+}
74+
75+// justMapped reports whether the window got the focus as it mapped: opening
76+// an app is not a focus change, and its first frames may still be on their
77+// way. Its focus counts from the next change.
78+func (c *Core) justMapped(id WindowID) bool {
79+ p := &c.pulse
80+ return p.target == id && p.since.Sub(c.windows.lookup(id).mappedAt) < pulseSettle
81+}
82+
83+// pulseTick runs when the settle timer fired. It reports whether a pulse
84+// started.
85+func (c *Core) pulseTick() bool {
86+ p := &c.pulse
87+ p.timerC, p.timerStop = nil, nil
88+ if p.target == 0 || !c.pulseOn() || !p.drawable || c.justMapped(p.target) {
89+ return false
90+ }
91+ now := c.now()
92+ if wait := pulseSettle - now.Sub(p.since); wait > 0 {
93+ p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)
94+ return false
95+ }
96+ if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
97+ return false
98+ }
99+ p.id, p.start, p.last, p.lastAt = p.target, now, p.target, now
100+ return true
101+}
102+
103+// advancePulse is the effect of the running pulse at now: a quick sine rise
104+// to focus.strength, then a smooth fall. It ends the pulse once over.
105+func (c *Core) advancePulse(now time.Time) float64 {
106+ p := &c.pulse
107+ if p.id == 0 {
108+ return 0
109+ }
110+ peak := c.cfg.Focus.Strength
111+ t := now.Sub(p.start)
112+ switch {
113+ case t < 0:
114+ return 0
115+ case t < pulseRise:
116+ return peak * math.Sin(float64(t)/float64(pulseRise)*math.Pi/2)
117+ case t < pulseRise+pulseFall:
118+ x := 1 - float64(t-pulseRise)/float64(pulseFall)
119+ return peak * x * x * (3 - 2*x)
120+ }
121+ p.id = 0
122+ return 0
123+}
124+
125+// pulsing reports whether a pulse runs on the screen.
126+func (c *Core) pulsing(sc *screen) bool {
127+ return c.pulse.id != 0 && sc == c.cur()
128+}
129+
130+func (c *Core) stopPulseTimer() {
131+ p := &c.pulse
132+ if p.timerStop != nil {
133+ p.timerStop()
134+ }
135+ p.timerC, p.timerStop = nil, nil
136+}
137+
138+// stopPulse ends the running pulse and its timer (security change). The
139+// focus is kept: the window focused again after an unlock does not pulse.
140+func (c *Core) stopPulse() {
141+ c.stopPulseTimer()
142+ c.pulse.id, c.pulse.drawable = 0, false
143+}
@@ -0,0 +1,143 @@
1+package core
2+
3+import (
4+ "math"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// The focus pulse is the pulse animation of the focus indicator
11+// (focus.animation): it briefly shows the indicator's effect (focus.effect, a
12+// light screen blend visible on black backgrounds too) on a window that just
13+// got the keyboard focus. Only the last focus counts: it must hold for
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
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.
19+
20+const (
21+ pulseSettle = 150 * time.Millisecond
22+ pulseRise = 90 * time.Millisecond
23+ pulseFall = 230 * time.Millisecond
24+ pulseCooldown = time.Second
25+)
26+
27+// focusPulse is the pulse state. target is the window focus last seen and
28+// since when; id is the window pulsing from start (0: none). last and lastAt
29+// are the previous pulse, for the cooldown. drawable is whether the last
30+// scene could show a pulse on target.
31+type focusPulse struct {
32+ target WindowID
33+ since time.Time
34+ id WindowID
35+ start time.Time
36+ last WindowID
37+ lastAt time.Time
38+ drawable bool
39+ timerC <-chan time.Time
40+ timerStop func() bool
41+}
42+
43+func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse }
44+
45+// pulseFocus follows the window focus. held is false while something else
46+// (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
47+// stops, and the window getting it back is not a new focus. A new window
48+// stops the running pulse and waits pulseSettle before one starts. With the
49+// pulse off the focus is still followed, so turning it on pulses nothing.
50+func (c *Core) pulseFocus(window WindowID, held bool) {
51+ p := &c.pulse
52+ if !held || !c.pulseOn() {
53+ p.id = 0
54+ c.stopPulseTimer()
55+ if held {
56+ p.target = window
57+ }
58+ return
59+ }
60+ if window == p.target {
61+ return
62+ }
63+ p.target, p.id = window, 0
64+ c.stopPulseTimer()
65+ if window == 0 {
66+ return
67+ }
68+ p.since = c.now()
69+ if c.justMapped(window) {
70+ return
71+ }
72+ p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseSettle)
73+}
74+
75+// justMapped reports whether the window got the focus as it mapped: opening
76+// an app is not a focus change, and its first frames may still be on their
77+// way. Its focus counts from the next change.
78+func (c *Core) justMapped(id WindowID) bool {
79+ p := &c.pulse
80+ return p.target == id && p.since.Sub(c.windows.lookup(id).mappedAt) < pulseSettle
81+}
82+
83+// pulseTick runs when the settle timer fired. It reports whether a pulse
84+// started.
85+func (c *Core) pulseTick() bool {
86+ p := &c.pulse
87+ p.timerC, p.timerStop = nil, nil
88+ if p.target == 0 || !c.pulseOn() || !p.drawable || c.justMapped(p.target) {
89+ return false
90+ }
91+ now := c.now()
92+ if wait := pulseSettle - now.Sub(p.since); wait > 0 {
93+ p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)
94+ return false
95+ }
96+ if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
97+ return false
98+ }
99+ p.id, p.start, p.last, p.lastAt = p.target, now, p.target, now
100+ return true
101+}
102+
103+// advancePulse is the effect of the running pulse at now: a quick sine rise
104+// to focus.strength, then a smooth fall. It ends the pulse once over.
105+func (c *Core) advancePulse(now time.Time) float64 {
106+ p := &c.pulse
107+ if p.id == 0 {
108+ return 0
109+ }
110+ peak := c.cfg.Focus.Strength
111+ t := now.Sub(p.start)
112+ switch {
113+ case t < 0:
114+ return 0
115+ case t < pulseRise:
116+ return peak * math.Sin(float64(t)/float64(pulseRise)*math.Pi/2)
117+ case t < pulseRise+pulseFall:
118+ x := 1 - float64(t-pulseRise)/float64(pulseFall)
119+ return peak * x * x * (3 - 2*x)
120+ }
121+ p.id = 0
122+ return 0
123+}
124+
125+// pulsing reports whether a pulse runs on the screen.
126+func (c *Core) pulsing(sc *screen) bool {
127+ return c.pulse.id != 0 && sc == c.cur()
128+}
129+
130+func (c *Core) stopPulseTimer() {
131+ p := &c.pulse
132+ if p.timerStop != nil {
133+ p.timerStop()
134+ }
135+ p.timerC, p.timerStop = nil, nil
136+}
137+
138+// stopPulse ends the running pulse and its timer (security change). The
139+// focus is kept: the window focused again after an unlock does not pulse.
140+func (c *Core) stopPulse() {
141+ c.stopPulseTimer()
142+ c.pulse.id, c.pulse.drawable = 0, false
143+}
A internal/core/pulse_test.go +205 -0
@@ -0,0 +1,205 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// pulseCore is indicatorCore with the focus pulse on and two tiled windows,
11+// 2 focused, its pulse settled and over.
12+func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13+ t.Helper()
14+ c, ic := indicatorCore(t)
15+ c.cfg.Focus.Animation = ports.FocusAnimationPulse
16+ c.cfg.Focus.Strength = 0.05
17+ c.cur().mon.AddWindow(1)
18+ c.cur().mon.AddWindow(2)
19+ indicatorScene(t, c)
20+ ic.now = ic.now.Add(pulseSettle)
21+ c.pulseTick()
22+ ic.now = ic.now.Add(pulseCooldown)
23+ indicatorScene(t, c)
24+ return c, ic
25+}
26+
27+func pulseOf(s ports.Scene, id WindowID) float64 {
28+ for _, w := range s.Windows {
29+ if w.ID == id {
30+ return w.FocusEffect
31+ }
32+ }
33+ return -1
34+}
35+
36+func TestFocusPulseAfterSettle(t *testing.T) {
37+ c, ic := pulseCore(t)
38+ c.cur().mon.Current().FocusID(1)
39+ if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
40+ t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
41+ }
42+ ic.now = ic.now.Add(pulseSettle)
43+ if !c.pulseTick() {
44+ t.Fatal("no pulse after settle")
45+ }
46+ if !c.animating() {
47+ t.Fatal("a running pulse asks for no frames")
48+ }
49+ ic.now = ic.now.Add(pulseRise)
50+ s := indicatorScene(t, c)
51+ if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
52+ t.Fatalf("peak %v, want the configured 0.05", p)
53+ }
54+ if pulseOf(s, 2) != 0 {
55+ t.Fatal("unfocused window pulses")
56+ }
57+ ic.now = ic.now.Add(pulseFall)
58+ if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
59+ t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
60+ }
61+}
62+
63+// Switching faster than pulseSettle never pulses: only the last focus counts.
64+func TestFocusPulseDebounced(t *testing.T) {
65+ c, ic := pulseCore(t)
66+ for range 4 {
67+ c.cur().mon.Current().FocusID(1)
68+ indicatorScene(t, c)
69+ ic.now = ic.now.Add(pulseSettle / 3)
70+ c.cur().mon.Current().FocusID(2)
71+ indicatorScene(t, c)
72+ ic.now = ic.now.Add(pulseSettle / 3)
73+ }
74+ // A stale timer of an earlier focus fires: the focus is too recent.
75+ if c.pulseTick() {
76+ t.Fatal("pulse while switching")
77+ }
78+ if c.pulse.timerC == nil {
79+ t.Fatal("no timer for the last focus")
80+ }
81+ ic.now = ic.now.Add(pulseSettle)
82+ // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
83+ if !c.pulseTick() || c.pulse.id != 2 {
84+ t.Fatalf("last focus did not pulse: %d", c.pulse.id)
85+ }
86+}
87+
88+// A focus change stops the running pulse; coming back within the cooldown
89+// does not pulse the same window again.
90+func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
91+ c, ic := pulseCore(t)
92+ c.cur().mon.Current().FocusID(1)
93+ indicatorScene(t, c)
94+ ic.now = ic.now.Add(pulseSettle)
95+ c.pulseTick()
96+ ic.now = ic.now.Add(pulseRise / 2)
97+ c.cur().mon.Current().FocusID(2)
98+ if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
99+ t.Fatal("pulse survived a focus change")
100+ }
101+ c.cur().mon.Current().FocusID(1)
102+ indicatorScene(t, c)
103+ ic.now = ic.now.Add(pulseSettle)
104+ if c.pulseTick() {
105+ t.Fatal("same window pulsed within the cooldown")
106+ }
107+}
108+
109+// A window focused as it maps does not pulse; focusing it again later does.
110+func TestFocusPulseSkipsNewWindow(t *testing.T) {
111+ c, ic := pulseCore(t)
112+ c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
113+ c.cur().mon.AddWindow(3)
114+ indicatorScene(t, c)
115+ if c.pulse.timerC != nil {
116+ t.Fatal("opening a window arms the pulse")
117+ }
118+ ic.now = ic.now.Add(pulseSettle)
119+ if c.pulseTick() || c.animating() {
120+ t.Fatal("a new window pulses")
121+ }
122+ c.cur().mon.Current().FocusID(1)
123+ indicatorScene(t, c)
124+ c.cur().mon.Current().FocusID(3)
125+ indicatorScene(t, c)
126+ ic.now = ic.now.Add(pulseSettle)
127+ if !c.pulseTick() || c.pulse.id != 3 {
128+ t.Fatal("an existing window did not pulse")
129+ }
130+}
131+
132+// A window that mapped earlier without the focus pulses when focused.
133+func TestFocusPulseWindowMappedEarlier(t *testing.T) {
134+ c, ic := pulseCore(t)
135+ c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
136+ c.cur().mon.Current().FocusID(1)
137+ indicatorScene(t, c)
138+ ic.now = ic.now.Add(pulseSettle)
139+ if !c.pulseTick() || c.pulse.id != 1 {
140+ t.Fatal("a window mapped earlier did not pulse")
141+ }
142+}
143+
144+func TestFocusPulseSkipsFullscreen(t *testing.T) {
145+ c, ic := pulseCore(t)
146+ c.cur().mon.Current().FocusID(1)
147+ c.cur().mon.ToggleFullscreen()
148+ indicatorScene(t, c)
149+ ic.now = ic.now.Add(pulseSettle)
150+ if c.pulseTick() || c.animating() {
151+ t.Fatal("fullscreen window asks for pulse frames")
152+ }
153+ // Fullscreen entered mid-pulse ends it.
154+ c.cur().mon.ToggleFullscreen()
155+ c.cur().mon.Current().FocusID(2)
156+ indicatorScene(t, c)
157+ ic.now = ic.now.Add(pulseSettle)
158+ if !c.pulseTick() {
159+ t.Fatal("no pulse on the tiled window")
160+ }
161+ c.cur().mon.ToggleFullscreen()
162+ if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
163+ t.Fatal("pulse survived fullscreen")
164+ }
165+}
166+
167+// 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.
169+func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
170+ c, ic := pulseCore(t)
171+ c.cur().mon.Current().FocusID(1)
172+ indicatorScene(t, c)
173+ ic.now = ic.now.Add(pulseSettle)
174+ c.pulseTick()
175+ // The keyboard goes elsewhere (held false), then comes back.
176+ c.pulseFocus(1, false)
177+ if c.animating() {
178+ t.Fatal("pulse kept while the keyboard is elsewhere")
179+ }
180+ ic.now = ic.now.Add(2 * pulseCooldown)
181+ c.pulseFocus(1, true)
182+ if c.pulse.timerC != nil {
183+ t.Fatal("returning keyboard counts as a new focus")
184+ }
185+}
186+
187+func TestFocusPulseOff(t *testing.T) {
188+ c, ic := pulseCore(t)
189+ c.cfg.Focus.Animation = ports.FocusAnimationOff
190+ c.cur().mon.Current().FocusID(1)
191+ indicatorScene(t, c)
192+ if c.pulse.timerC != nil {
193+ t.Fatal("timer armed with the pulse off")
194+ }
195+ ic.now = ic.now.Add(pulseSettle)
196+ if c.pulseTick() || c.animating() {
197+ t.Fatal("pulse with focus.animation off")
198+ }
199+ // Turning it back on does not pulse the window already focused.
200+ c.cfg.Focus.Animation = ports.FocusAnimationPulse
201+ indicatorScene(t, c)
202+ if c.pulse.timerC != nil {
203+ t.Fatal("enabling the pulse pulses the focused window")
204+ }
205+}
@@ -0,0 +1,205 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// pulseCore is indicatorCore with the focus pulse on and two tiled windows,
11+// 2 focused, its pulse settled and over.
12+func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13+ t.Helper()
14+ c, ic := indicatorCore(t)
15+ c.cfg.Focus.Animation = ports.FocusAnimationPulse
16+ c.cfg.Focus.Strength = 0.05
17+ c.cur().mon.AddWindow(1)
18+ c.cur().mon.AddWindow(2)
19+ indicatorScene(t, c)
20+ ic.now = ic.now.Add(pulseSettle)
21+ c.pulseTick()
22+ ic.now = ic.now.Add(pulseCooldown)
23+ indicatorScene(t, c)
24+ return c, ic
25+}
26+
27+func pulseOf(s ports.Scene, id WindowID) float64 {
28+ for _, w := range s.Windows {
29+ if w.ID == id {
30+ return w.FocusEffect
31+ }
32+ }
33+ return -1
34+}
35+
36+func TestFocusPulseAfterSettle(t *testing.T) {
37+ c, ic := pulseCore(t)
38+ c.cur().mon.Current().FocusID(1)
39+ if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
40+ t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
41+ }
42+ ic.now = ic.now.Add(pulseSettle)
43+ if !c.pulseTick() {
44+ t.Fatal("no pulse after settle")
45+ }
46+ if !c.animating() {
47+ t.Fatal("a running pulse asks for no frames")
48+ }
49+ ic.now = ic.now.Add(pulseRise)
50+ s := indicatorScene(t, c)
51+ if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
52+ t.Fatalf("peak %v, want the configured 0.05", p)
53+ }
54+ if pulseOf(s, 2) != 0 {
55+ t.Fatal("unfocused window pulses")
56+ }
57+ ic.now = ic.now.Add(pulseFall)
58+ if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
59+ t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
60+ }
61+}
62+
63+// Switching faster than pulseSettle never pulses: only the last focus counts.
64+func TestFocusPulseDebounced(t *testing.T) {
65+ c, ic := pulseCore(t)
66+ for range 4 {
67+ c.cur().mon.Current().FocusID(1)
68+ indicatorScene(t, c)
69+ ic.now = ic.now.Add(pulseSettle / 3)
70+ c.cur().mon.Current().FocusID(2)
71+ indicatorScene(t, c)
72+ ic.now = ic.now.Add(pulseSettle / 3)
73+ }
74+ // A stale timer of an earlier focus fires: the focus is too recent.
75+ if c.pulseTick() {
76+ t.Fatal("pulse while switching")
77+ }
78+ if c.pulse.timerC == nil {
79+ t.Fatal("no timer for the last focus")
80+ }
81+ ic.now = ic.now.Add(pulseSettle)
82+ // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
83+ if !c.pulseTick() || c.pulse.id != 2 {
84+ t.Fatalf("last focus did not pulse: %d", c.pulse.id)
85+ }
86+}
87+
88+// A focus change stops the running pulse; coming back within the cooldown
89+// does not pulse the same window again.
90+func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
91+ c, ic := pulseCore(t)
92+ c.cur().mon.Current().FocusID(1)
93+ indicatorScene(t, c)
94+ ic.now = ic.now.Add(pulseSettle)
95+ c.pulseTick()
96+ ic.now = ic.now.Add(pulseRise / 2)
97+ c.cur().mon.Current().FocusID(2)
98+ if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
99+ t.Fatal("pulse survived a focus change")
100+ }
101+ c.cur().mon.Current().FocusID(1)
102+ indicatorScene(t, c)
103+ ic.now = ic.now.Add(pulseSettle)
104+ if c.pulseTick() {
105+ t.Fatal("same window pulsed within the cooldown")
106+ }
107+}
108+
109+// A window focused as it maps does not pulse; focusing it again later does.
110+func TestFocusPulseSkipsNewWindow(t *testing.T) {
111+ c, ic := pulseCore(t)
112+ c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
113+ c.cur().mon.AddWindow(3)
114+ indicatorScene(t, c)
115+ if c.pulse.timerC != nil {
116+ t.Fatal("opening a window arms the pulse")
117+ }
118+ ic.now = ic.now.Add(pulseSettle)
119+ if c.pulseTick() || c.animating() {
120+ t.Fatal("a new window pulses")
121+ }
122+ c.cur().mon.Current().FocusID(1)
123+ indicatorScene(t, c)
124+ c.cur().mon.Current().FocusID(3)
125+ indicatorScene(t, c)
126+ ic.now = ic.now.Add(pulseSettle)
127+ if !c.pulseTick() || c.pulse.id != 3 {
128+ t.Fatal("an existing window did not pulse")
129+ }
130+}
131+
132+// A window that mapped earlier without the focus pulses when focused.
133+func TestFocusPulseWindowMappedEarlier(t *testing.T) {
134+ c, ic := pulseCore(t)
135+ c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
136+ c.cur().mon.Current().FocusID(1)
137+ indicatorScene(t, c)
138+ ic.now = ic.now.Add(pulseSettle)
139+ if !c.pulseTick() || c.pulse.id != 1 {
140+ t.Fatal("a window mapped earlier did not pulse")
141+ }
142+}
143+
144+func TestFocusPulseSkipsFullscreen(t *testing.T) {
145+ c, ic := pulseCore(t)
146+ c.cur().mon.Current().FocusID(1)
147+ c.cur().mon.ToggleFullscreen()
148+ indicatorScene(t, c)
149+ ic.now = ic.now.Add(pulseSettle)
150+ if c.pulseTick() || c.animating() {
151+ t.Fatal("fullscreen window asks for pulse frames")
152+ }
153+ // Fullscreen entered mid-pulse ends it.
154+ c.cur().mon.ToggleFullscreen()
155+ c.cur().mon.Current().FocusID(2)
156+ indicatorScene(t, c)
157+ ic.now = ic.now.Add(pulseSettle)
158+ if !c.pulseTick() {
159+ t.Fatal("no pulse on the tiled window")
160+ }
161+ c.cur().mon.ToggleFullscreen()
162+ if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
163+ t.Fatal("pulse survived fullscreen")
164+ }
165+}
166+
167+// 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.
169+func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
170+ c, ic := pulseCore(t)
171+ c.cur().mon.Current().FocusID(1)
172+ indicatorScene(t, c)
173+ ic.now = ic.now.Add(pulseSettle)
174+ c.pulseTick()
175+ // The keyboard goes elsewhere (held false), then comes back.
176+ c.pulseFocus(1, false)
177+ if c.animating() {
178+ t.Fatal("pulse kept while the keyboard is elsewhere")
179+ }
180+ ic.now = ic.now.Add(2 * pulseCooldown)
181+ c.pulseFocus(1, true)
182+ if c.pulse.timerC != nil {
183+ t.Fatal("returning keyboard counts as a new focus")
184+ }
185+}
186+
187+func TestFocusPulseOff(t *testing.T) {
188+ c, ic := pulseCore(t)
189+ c.cfg.Focus.Animation = ports.FocusAnimationOff
190+ c.cur().mon.Current().FocusID(1)
191+ indicatorScene(t, c)
192+ if c.pulse.timerC != nil {
193+ t.Fatal("timer armed with the pulse off")
194+ }
195+ ic.now = ic.now.Add(pulseSettle)
196+ if c.pulseTick() || c.animating() {
197+ t.Fatal("pulse with focus.animation off")
198+ }
199+ // Turning it back on does not pulse the window already focused.
200+ c.cfg.Focus.Animation = ports.FocusAnimationPulse
201+ indicatorScene(t, c)
202+ if c.pulse.timerC != nil {
203+ t.Fatal("enabling the pulse pulses the focused window")
204+ }
205+}
M internal/core/security.go +1 -0
@@ -40,6 +40,7 @@ func (c *Core) syncSecurity() bool {
4040 }
4141 }
4242 c.stopFrame()
43+ c.stopPulse()
4344 if state.Protected {
4445 c.dropCaptureSessions()
4546 }
@@ -40,6 +40,7 @@ func (c *Core) syncSecurity() bool {
40 }40 }
41 }41 }
42 c.stopFrame()42 c.stopFrame()
43+ c.stopPulse()
43 if state.Protected {44 if state.Protected {
44 c.dropCaptureSessions()45 c.dropCaptureSessions()
45 }46 }
M internal/ports/config.go +13 -0
@@ -59,6 +59,14 @@ type OutputLayout struct {
5959 LayoutRules
6060 }
6161
62+// Focus indicator: the animation (Config.Focus.Animation) shapes in time
63+// the visual effect (Config.Focus.Effect) drawn on a newly focused window.
64+const (
65+ FocusAnimationOff = "off"
66+ FocusAnimationPulse = "pulse"
67+ FocusEffectScreen = "screen"
68+)
69+
6270 // Config is the parsed compositor configuration (see the config adapter for keys).
6371 type Config struct {
6472 Keyboard struct {
@@ -107,6 +115,11 @@ type Config struct {
107115 // FollowMove shows the target workspace after a column or window
108116 // moves to it.
109117 FollowMove bool
118+ // Animation and Effect mark a window that just got the focus:
119+ // FocusAnimation* (off disables it) and FocusEffect*. Strength is
120+ // the effect's peak, 0.01 to 0.2.
121+ Animation, Effect string
122+ Strength float64
110123 }
111124 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
112125 Workspaces []WorkspaceConfig
@@ -59,6 +59,14 @@ type OutputLayout struct {
59 LayoutRules59 LayoutRules
60 }60 }
61 61
62+// Focus indicator: the animation (Config.Focus.Animation) shapes in time
63+// the visual effect (Config.Focus.Effect) drawn on a newly focused window.
64+const (
65+ FocusAnimationOff = "off"
66+ FocusAnimationPulse = "pulse"
67+ FocusEffectScreen = "screen"
68+)
69+
62 // Config is the parsed compositor configuration (see the config adapter for keys).70 // Config is the parsed compositor configuration (see the config adapter for keys).
63 type Config struct {71 type Config struct {
64 Keyboard struct {72 Keyboard struct {
@@ -107,6 +115,11 @@ type Config struct {
107 // FollowMove shows the target workspace after a column or window115 // FollowMove shows the target workspace after a column or window
108 // moves to it.116 // moves to it.
109 FollowMove bool117 FollowMove bool
118+ // Animation and Effect mark a window that just got the focus:
119+ // FocusAnimation* (off disables it) and FocusEffect*. Strength is
120+ // the effect's peak, 0.01 to 0.2.
121+ Animation, Effect string
122+ Strength float64
110 }123 }
111 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.124 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
112 Workspaces []WorkspaceConfig125 Workspaces []WorkspaceConfig
M internal/ports/scene.go +10 -0
@@ -320,6 +320,16 @@ type SceneWindow struct {
320320 // Dim darkens the window, border included, with black at this
321321 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
322322 Dim float64
323+ // FocusEffect, above 0, is the focus indicator's effect at this frame
324+ // (focus.animation, focus.effect): the window's surfaces are lifted
325+ // toward white by that fraction (screen blend). Renderers without the
326+ // effect ignore it.
327+ //
328+ // Only focus.effect = screen exists, so the effect kind is not carried:
329+ // core ignores Config.Focus.Effect and the shaders always apply screen
330+ // (compose.frag, compose_hdr.frag, push constant mapy.z). A second effect
331+ // needs its kind here and in the push constants (a misc flag).
332+ FocusEffect float64
323333 // Preview, above 0, draws the window's surfaces that much smaller in
324334 // Rect (an overview thumbnail): the client keeps its size.
325335 Preview float64
@@ -320,6 +320,16 @@ type SceneWindow struct {
320 // Dim darkens the window, border included, with black at this320 // Dim darkens the window, border included, with black at this
321 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.321 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
322 Dim float64322 Dim float64
323+ // FocusEffect, above 0, is the focus indicator's effect at this frame
324+ // (focus.animation, focus.effect): the window's surfaces are lifted
325+ // toward white by that fraction (screen blend). Renderers without the
326+ // effect ignore it.
327+ //
328+ // Only focus.effect = screen exists, so the effect kind is not carried:
329+ // core ignores Config.Focus.Effect and the shaders always apply screen
330+ // (compose.frag, compose_hdr.frag, push constant mapy.z). A second effect
331+ // needs its kind here and in the push constants (a misc flag).
332+ FocusEffect float64
323 // Preview, above 0, draws the window's surfaces that much smaller in333 // Preview, above 0, draws the window's surfaces that much smaller in
324 // Rect (an overview thumbnail): the client keeps its size.334 // Rect (an overview thumbnail): the client keeps its size.
325 Preview float64335 Preview float64

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