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 Unverified
B5690EEEBB952194The 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`). | |||
| 39 | 39 | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | |
| 40 | 40 | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | |
| 41 | 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 | 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 | 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 | 47 | | `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 | |||
| 59 | 59 | ||
| 60 | 60 | # on: moving a column or window to another workspace shows that workspace. | |
| 61 | 61 | 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 | 69 | # Named workspaces are outside the numbered list and require a bind. The bind | |
| 64 | 70 | # 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 = off | 61 | 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 bind | 69 | # 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 { | |||
| 122 | 122 | c.Touchpad.Tap = true | |
| 123 | 123 | c.Touchpad.AccelProfile = ports.AccelAdaptive | |
| 124 | 124 | c.Touchpad.ScrollFactor = 1 | |
| 125 | + | c.Focus.Animation = ports.FocusAnimationPulse | |
| 126 | + | c.Focus.Effect = ports.FocusEffectScreen | |
| 127 | + | c.Focus.Strength = 0.04 | |
| 125 | 128 | c.Binds = map[string]string{} | |
| 126 | 129 | for _, b := range defaultBinds { | |
| 127 | 130 | combos := []string{b.combo} | |
| @@ -585,6 +588,23 @@ func set(c *ports.Config, key, v string) error { | |||
| 585 | 588 | return err | |
| 586 | 589 | } | |
| 587 | 590 | 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 | 608 | case "render.direct-scanout": | |
| 589 | 609 | b, err := onOff(v) | |
| 590 | 610 | if err != nil { | |
| @@ -122,6 +122,9 @@ func Defaults() ports.Config { | |||||
| 122 | c.Touchpad.Tap = true | 122 | c.Touchpad.Tap = true | ||
| 123 | c.Touchpad.AccelProfile = ports.AccelAdaptive | 123 | c.Touchpad.AccelProfile = ports.AccelAdaptive | ||
| 124 | c.Touchpad.ScrollFactor = 1 | 124 | 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 err | 588 | return err | ||
| 586 | } | 589 | } | ||
| 587 | c.Focus.FollowMove = b | 590 | 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) { | |||
| 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 | 98 | func TestTouchpadNaturalScroll(t *testing.T) { | |
| 81 | 99 | if Defaults().Touchpad.NaturalScroll { | |
| 82 | 100 | 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 | |||
| 75 | 75 | c := surfaces[w.ID] | |
| 76 | 76 | // A viewport crop or PQ content is composed: the plane would show | |
| 77 | 77 | // 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() { | |
| 81 | 82 | pick = w | |
| 82 | 83 | continue | |
| 83 | 84 | } | |
| @@ -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 show | 76 | // 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 dimmed | 77 | // 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 | 78 | + | // 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 = w | 82 | pick = w | ||
| 82 | continue | 83 | continue | ||
| 83 | } | 84 | } | ||
M internal/adapters/drm/overlay_test.go +6 -0
| @@ -107,6 +107,12 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 107 | 107 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 108 | 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 | 116 | // An overview preview is drawn smaller than its buffer: composed. | |
| 111 | 117 | s, _ = overlayScene() | |
| 112 | 118 | 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.5 | 118 | 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) { | |||
| 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 | 194 | func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) { | |
| 176 | 195 | r := hdrTestRenderer(t) | |
| 177 | 196 | 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) { | |||
| 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 | 138 | func TestRendererContents(t *testing.T) { | |
| 115 | 139 | r, err := New(64, 48) | |
| 116 | 140 | 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 { | |||
| 19 | 19 | scale float64 | |
| 20 | 20 | // zoom shrinks the surfaces of the window being placed: an overview | |
| 21 | 21 | // 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 | |
| 23 | 26 | bounds image.Rectangle | |
| 24 | 27 | draws []draw | |
| 25 | 28 | } | |
| @@ -186,8 +189,9 @@ func (w *sceneWalk) windows() { | |||
| 186 | 189 | if win.Preview > 0 { | |
| 187 | 190 | w.zoom = win.Preview | |
| 188 | 191 | } | |
| 192 | + | w.pulse = float32(max(0, min(win.FocusEffect, 1))) | |
| 189 | 193 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | |
| 190 | - | w.zoom = 1 | |
| 194 | + | w.zoom, w.pulse = 1, 0 | |
| 191 | 195 | } | |
| 192 | 196 | if win.Floating { | |
| 193 | 197 | w.separators(win.ID) | |
| @@ -360,6 +364,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 360 | 364 | } | |
| 361 | 365 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque) | |
| 362 | 366 | r.setContentColor(&dr, content) | |
| 367 | + | dr.pc.mapy[2] = w.pulse | |
| 363 | 368 | if im.yuv { | |
| 364 | 369 | dr.pc.misc[1] |= flagYUV | |
| 365 | 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 | 402 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque) | |
| 398 | 403 | r.setContentColor(&dr, content) | |
| 404 | + | dr.pc.mapy[2] = w.pulse | |
| 399 | 405 | dr.set = c.set | |
| 400 | 406 | dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0} | |
| 401 | 407 | w.draws = append(w.draws, dr) | |
| @@ -19,7 +19,10 @@ type sceneWalk struct { | |||||
| 19 | scale float64 | 19 | scale float64 | ||
| 20 | // zoom shrinks the surfaces of the window being placed: an overview | 20 | // 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 float64 | 22 | + | 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.Rectangle | 26 | bounds image.Rectangle | ||
| 24 | draws []draw | 27 | 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.Preview | 190 | 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 = 1 | 194 | + | 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] |= flagYUV | 369 | 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.set | 405 | 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() { | |||
| 132 | 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 | 141 | if (d.misc.x != modeSolid) { | |
| 136 | 142 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 137 | 143 | 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 @@ | |||
| 4 | 4 | layout(push_constant) uniform Draw { | |
| 5 | 5 | ivec4 rect; // visible x0, y0, x1, y1 in target pixels | |
| 6 | 6 | 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 | |
| 8 | 8 | vec4 color; // solid fills: premultiplied RGBA | |
| 9 | 9 | vec4 crop; // source crop bounds in buffer pixels | |
| 10 | 10 | 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 pixels | 5 | ivec4 rect; // visible x0, y0, x1, y1 in target pixels | ||
| 6 | vec4 map; // source texel = map.xy + fragment centre.x * map.zw | 6 | 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 |
| 8 | vec4 color; // solid fills: premultiplied RGBA | 8 | vec4 color; // solid fills: premultiplied RGBA | ||
| 9 | vec4 crop; // source crop bounds in buffer pixels | 9 | vec4 crop; // source crop bounds in buffer pixels | ||
| 10 | uvec4 buf; // buffer draws: first word, row width, height, unused | 10 | 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() { | |||
| 126 | 126 | if (c.a > 0.0 && d.misc.x != modeSolid) { | |
| 127 | 127 | // ext_linear has the protocol's default 80 cd/m² reference white. | |
| 128 | 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) : | |
| 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); | |
| 131 | 138 | c.rgb = linearRGB * c.a; | |
| 132 | 139 | } | |
| 133 | 140 | 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 | |||
| 473 | 473 | rects := make([]string, 0, len(s.Windows)) | |
| 474 | 474 | for _, w := range s.Windows { | |
| 475 | 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 | 481 | ev.Strs("windows", rects).Msg("scene") | |
| 478 | 482 | } | |
| @@ -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) { | |||
| 222 | 222 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 223 | 223 | cfg := config.Defaults() | |
| 224 | 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 | 227 | scenes := make(chan []ports.Scene, 128) | |
| 226 | 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 | 229 | 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) { | |||
| 126 | 126 | first = f.until | |
| 127 | 127 | } | |
| 128 | 128 | } | |
| 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)) | |
| 137 | 130 | } | |
| 138 | 131 | ||
| 139 | 132 | func (s *captureState) stopTimer() { | |
| @@ -126,14 +126,7 @@ func (s *captureState) armTimer(now time.Time, clock ports.Clock) { | |||||
| 126 | first = f.until | 126 | 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 { | |||
| 133 | 133 | pointerAt [2]float64 | |
| 134 | 134 | // capt is the capture state (capture.go, capindicator.go). | |
| 135 | 135 | capt captureState | |
| 136 | + | // pulse marks a window that just got the focus (pulse.go). | |
| 137 | + | pulse focusPulse | |
| 136 | 138 | } | |
| 137 | 139 | ||
| 138 | 140 | func keyName(s string) string { | |
| @@ -347,6 +349,17 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 347 | 349 | } | |
| 348 | 350 | 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 | 363 | func (c *Core) now() time.Time { | |
| 351 | 364 | if c.ch.Clock != nil { | |
| 352 | 365 | return c.ch.Clock.Now() | |
| @@ -512,6 +525,14 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 512 | 525 | return err | |
| 513 | 526 | } | |
| 514 | 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 | 536 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 516 | 537 | // Before the first output (and after the last is unplugged) the | |
| 517 | 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 | 575 | if p.Peek { | |
| 555 | 576 | 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 | 584 | scene.Windows = append(scene.Windows, sw) | |
| 558 | 585 | t := configureTarget{output: sc.name(), area: frame, focused: focused} | |
| 559 | 586 | if !p.Hidden && p.Preview == 0 { | |
| @@ -581,6 +608,10 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 581 | 608 | scenes = append(scenes, scene) | |
| 582 | 609 | } | |
| 583 | 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 | 615 | // A workspace switch can hide the window under the pointer; it must not get | |
| 585 | 616 | // clicks. The next motion re-runs hit-testing. | |
| 586 | 617 | if c.pointer != 0 && !c.visible(c.pointer) { | |
| @@ -917,6 +948,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 917 | 948 | c.syncSecurity() | |
| 918 | 949 | defer c.stopFrame() | |
| 919 | 950 | defer c.stopCaptureTimer() | |
| 951 | + | defer c.stopPulseTimer() | |
| 920 | 952 | // Startup commands run once per session. Full launcher queues retain a | |
| 921 | 953 | // bounded remainder, selectable alongside owner events without polling. | |
| 922 | 954 | c.startup = make([][]string, len(c.cfg.Startup)) | |
| @@ -1104,6 +1136,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1104 | 1136 | return nil | |
| 1105 | 1137 | } | |
| 1106 | 1138 | 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 | 1147 | case <-c.capt.timerC: | |
| 1108 | 1148 | c.capt.timerC, c.capt.timerStop = nil, nil | |
| 1109 | 1149 | if c.captureFlashTick() && c.publish(ctx) != nil { | |
| @@ -133,6 +133,8 @@ type Core struct { | |||||
| 133 | pointerAt [2]float64 | 133 | 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 captureState | 135 | 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, nil | 350 | 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 err | 525 | 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) the | 537 | // 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.Dim | 576 | 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 get | 615 | // 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 a | 952 | // 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 nil | 1136 | return nil | ||
| 1105 | } | 1137 | } | ||
| 1106 | continue | 1138 | 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, nil | 1148 | 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) { | |||
| 803 | 803 | func TestFullscreenAtMapIgnored(t *testing.T) { | |
| 804 | 804 | cfg := config.Defaults() | |
| 805 | 805 | cfg.Layout.Overflow = "fixed" | |
| 806 | + | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 806 | 807 | client := make(chan ports.ClientEvent, 8) | |
| 807 | 808 | output := make(chan ports.OutputEvent, 8) | |
| 808 | 809 | commands := make(chan ports.ClientCommand, 64) | |
| @@ -882,6 +883,7 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||
| 882 | 883 | // A taskbar's fullscreen request is the user's: it applies at once. | |
| 883 | 884 | func TestExternalFullscreenAtMapApplies(t *testing.T) { | |
| 884 | 885 | cfg := config.Defaults() | |
| 886 | + | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 885 | 887 | client := make(chan ports.ClientEvent, 8) | |
| 886 | 888 | output := make(chan ports.OutputEvent, 8) | |
| 887 | 889 | 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) { | |||
| 343 | 343 | // sliding reports whether a spring runs on the named output. | |
| 344 | 344 | func (c *Core) sliding(output string) bool { | |
| 345 | 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 | 350 | func (c *Core) animating() bool { | |
| 351 | + | if c.pulse.id != 0 { | |
| 352 | + | return true | |
| 353 | + | } | |
| 351 | 354 | for _, sc := range c.screens { | |
| 352 | 355 | if sc.mon.springing() { | |
| 353 | 356 | return true | |
| @@ -373,7 +376,7 @@ func (m *Monitor) springing() bool { | |||
| 373 | 376 | func (c *Core) frameFallback() time.Duration { | |
| 374 | 377 | refresh := 60000 | |
| 375 | 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 | 380 | refresh = min(refresh, r) | |
| 378 | 381 | } | |
| 379 | 382 | } | |
| @@ -383,14 +386,7 @@ func (c *Core) frameFallback() time.Duration { | |||
| 383 | 386 | // armFrame starts the fallback timer of a running slide. | |
| 384 | 387 | func (c *Core) armFrame() { | |
| 385 | 388 | 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()) | |
| 394 | 390 | } | |
| 395 | 391 | ||
| 396 | 392 | 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 true | 356 | 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 := 60000 | 377 | 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 ( | |||
| 10 | 10 | "github.com/bnema/neferwl/internal/core" | |
| 11 | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 12 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 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 | 23 | now = now.Add(time.Second) | |
| 23 | 24 | return now | |
| 24 | 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 | 33 | return clock | |
| 26 | 34 | } | |
| 27 | 35 | ||
| @@ -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 now | 24 | 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 clock | 33 | 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 { | |||
| 40 | 40 | } | |
| 41 | 41 | } | |
| 42 | 42 | c.stopFrame() | |
| 43 | + | c.stopPulse() | |
| 43 | 44 | if state.Protected { | |
| 44 | 45 | c.dropCaptureSessions() | |
| 45 | 46 | } | |
| @@ -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 { | |||
| 59 | 59 | 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 | 70 | // Config is the parsed compositor configuration (see the config adapter for keys). | |
| 63 | 71 | type Config struct { | |
| 64 | 72 | Keyboard struct { | |
| @@ -107,6 +115,11 @@ type Config struct { | |||
| 107 | 115 | // FollowMove shows the target workspace after a column or window | |
| 108 | 116 | // moves to it. | |
| 109 | 117 | 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 | 124 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 112 | 125 | Workspaces []WorkspaceConfig | |
| @@ -59,6 +59,14 @@ type OutputLayout struct { | |||||
| 59 | LayoutRules | 59 | 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 window | 115 | // FollowMove shows the target workspace after a column or window | ||
| 108 | // moves to it. | 116 | // moves to it. | ||
| 109 | FollowMove bool | 117 | 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 []WorkspaceConfig | 125 | Workspaces []WorkspaceConfig | ||
M internal/ports/scene.go +10 -0
| @@ -320,6 +320,16 @@ type SceneWindow struct { | |||
| 320 | 320 | // Dim darkens the window, border included, with black at this | |
| 321 | 321 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |
| 322 | 322 | 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 | 333 | // Preview, above 0, draws the window's surfaces that much smaller in | |
| 324 | 334 | // Rect (an overview thumbnail): the client keeps its size. | |
| 325 | 335 | Preview float64 | |
| @@ -320,6 +320,16 @@ type SceneWindow struct { | |||||
| 320 | // Dim darkens the window, border included, with black at this | 320 | // 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 float64 | 322 | 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 in | 333 | // 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 float64 | 335 | Preview float64 | ||