detached ade9e82

refactor(core): remove focus pulse and add focus flash contracts

Remove the focus pulse from core, ports, vulkan shaders and config. Add ports.FocusFlash, Scene.Flash and the focus.flash* config keys; no flash behaviour yet.
commit
ade9e829e53a1afc20f4550e577852ca32e23349
author
brice · · 2026-10-19 04:27 UTC
parent
dc5dff6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
38 files · +140 -828
M README.md +4 -3
@@ -225,9 +225,10 @@ cursor.hide-after = 5s
225225 focus.follow-move = off
226226 animations = off
227227 animations.speed = normal
228-focus.animation = pulse
229-focus.effect = screen
230-focus.strength = 0.04
228+focus.flash = on
229+focus.flash-width = 2
230+focus.flash-opacity = 0.6
231+focus.flash-duration = 200ms
231232
232233 # Rendering and logging
233234 render.direct-scanout = on
@@ -225,9 +225,10 @@ cursor.hide-after = 5s
225 focus.follow-move = off225 focus.follow-move = off
226 animations = off226 animations = off
227 animations.speed = normal227 animations.speed = normal
228-focus.animation = pulse228+focus.flash = on
229-focus.effect = screen229+focus.flash-width = 2
230-focus.strength = 0.04230+focus.flash-opacity = 0.6
231+focus.flash-duration = 200ms
231 232
232 # Rendering and logging233 # Rendering and logging
233 render.direct-scanout = on234 render.direct-scanout = on
M docs/config.md +5 -2
@@ -50,8 +50,11 @@ See [examples/config](../examples/config) for every key with its default.
5050 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |
5151 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
5252 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
53-| `focus.animation` | `pulse` | Brief brightening of a newly focused window, or `off`. Needs `animations = on` |
54-| `focus.strength` | `0.04` | Strength of the focus pulse, `0.01` to `0.2` |
53+| `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |
54+| `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
55+| `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
56+| `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
57+| `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |
5558 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |
5659 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |
5760 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |
@@ -50,8 +50,11 @@ See [examples/config](../examples/config) for every key with its default.
50 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |50 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |
51 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |51 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
52 | `focus.follow-move` | `off` | Follow a column moved to another workspace |52 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
53-| `focus.animation` | `pulse` | Brief brightening of a newly focused window, or `off`. Needs `animations = on` |53+| `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |
54-| `focus.strength` | `0.04` | Strength of the focus pulse, `0.01` to `0.2` |54+| `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
55+| `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
56+| `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
57+| `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |
55 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |58 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |
56 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |59 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |
57 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |60 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |
M examples/config +9 -6
@@ -85,12 +85,15 @@ focus.follow-move = off
8585 # (10x faster).
8686 animations = off
8787 animations.speed = normal
88-# Focus indicator on the newly focused window: an animation (pulse or off)
89-# of an effect (screen: a slight brightening), peaking at strength
90-# (0.01 to 0.2; 0.04: black reaches about 10/255).
91-focus.animation = pulse
92-focus.effect = screen
93-focus.strength = 0.04
88+# Focus flash: a brief inner border on the window, column or output an
89+# action just changed. Width in logical pixels (1 to 8), starting opacity
90+# (0.1 to 1), fade duration (50ms to 1s, not scaled by animations.speed),
91+# color #rrggbb (empty: border.active).
92+focus.flash = on
93+focus.flash-width = 2
94+focus.flash-opacity = 0.6
95+focus.flash-duration = 200ms
96+focus.flash-color =
9497
9598 # Named workspaces are outside the numbered list and require a bind. The bind
9699 # shows one; pressing it again returns to the previous workspace.
@@ -85,12 +85,15 @@ focus.follow-move = off
85 # (10x faster).85 # (10x faster).
86 animations = off86 animations = off
87 animations.speed = normal87 animations.speed = normal
88-# Focus indicator on the newly focused window: an animation (pulse or off)88+# Focus flash: a brief inner border on the window, column or output an
89-# of an effect (screen: a slight brightening), peaking at strength89+# action just changed. Width in logical pixels (1 to 8), starting opacity
90-# (0.01 to 0.2; 0.04: black reaches about 10/255).90+# (0.1 to 1), fade duration (50ms to 1s, not scaled by animations.speed),
91-focus.animation = pulse91+# color #rrggbb (empty: border.active).
92-focus.effect = screen92+focus.flash = on
93-focus.strength = 0.0493+focus.flash-width = 2
94+focus.flash-opacity = 0.6
95+focus.flash-duration = 200ms
96+focus.flash-color =
94 97
95 # Named workspaces are outside the numbered list and require a bind. The bind98 # Named workspaces are outside the numbered list and require a bind. The bind
96 # shows one; pressing it again returns to the previous workspace.99 # shows one; pressing it again returns to the previous workspace.
M internal/adapters/config/config.go +31 -18
@@ -184,9 +184,10 @@ func Defaults() ports.Config {
184184 // Animations are experimental: off unless the config turns them on.
185185 c.Animations.On = false
186186 c.Animations.Slowdown = normalSlowdown
187- c.Focus.Animation = ports.FocusAnimationPulse
188- c.Focus.Effect = ports.FocusEffectScreen
189- c.Focus.Strength = 0.04
187+ c.Focus.Flash = true
188+ c.Focus.FlashWidth = 2
189+ c.Focus.FlashOpacity = 0.6
190+ c.Focus.FlashDuration = 200 * time.Millisecond
190191 c.Binds = map[string]string{}
191192 for _, b := range defaultBinds {
192193 combos := []string{b.combo}
@@ -840,23 +841,35 @@ func set(c *ports.Config, key, v string) error {
840841 return err
841842 }
842843 c.Focus.FollowMove = b
843- case "focus.animation":
844- if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {
845- return fmt.Errorf("must be pulse or off")
846- }
847- c.Focus.Animation = v
848- case "focus.effect":
849- if v != ports.FocusEffectScreen {
850- return fmt.Errorf("must be screen")
851- }
852- c.Focus.Effect = v
853- case "focus.strength":
854- // Capped low: the focus indicator must never flash.
844+ case "focus.flash":
845+ b, err := onOff(v)
846+ if err != nil {
847+ return err
848+ }
849+ c.Focus.Flash = b
850+ case "focus.flash-width":
851+ n, err := strconv.Atoi(v)
852+ if err != nil || n < 1 || n > 8 {
853+ return fmt.Errorf("must be an integer between 1 and 8")
854+ }
855+ c.Focus.FlashWidth = n
856+ case "focus.flash-opacity":
855857 f, err := strconv.ParseFloat(v, 64)
856- if err != nil || !(f >= 0.01 && f <= 0.2) {
857- return fmt.Errorf("must be between 0.01 and 0.2")
858+ if err != nil || !(f >= 0.1 && f <= 1) {
859+ return fmt.Errorf("must be between 0.1 and 1")
860+ }
861+ c.Focus.FlashOpacity = f
862+ case "focus.flash-duration":
863+ d, err := time.ParseDuration(v)
864+ if err != nil || d < 50*time.Millisecond || d > time.Second {
865+ return fmt.Errorf("must be a duration between 50ms and 1s, e.g. 200ms")
866+ }
867+ c.Focus.FlashDuration = d
868+ case "focus.flash-color":
869+ if v != "" && !color.MatchString(v) {
870+ return fmt.Errorf("must be #rrggbb or empty (border.active)")
858871 }
859- c.Focus.Strength = f
872+ c.Focus.FlashColor = v
860873 case "render.direct-scanout":
861874 b, err := onOff(v)
862875 if err != nil {
@@ -184,9 +184,10 @@ func Defaults() ports.Config {
184 // Animations are experimental: off unless the config turns them on.184 // Animations are experimental: off unless the config turns them on.
185 c.Animations.On = false185 c.Animations.On = false
186 c.Animations.Slowdown = normalSlowdown186 c.Animations.Slowdown = normalSlowdown
187- c.Focus.Animation = ports.FocusAnimationPulse187+ c.Focus.Flash = true
188- c.Focus.Effect = ports.FocusEffectScreen188+ c.Focus.FlashWidth = 2
189- c.Focus.Strength = 0.04189+ c.Focus.FlashOpacity = 0.6
190+ c.Focus.FlashDuration = 200 * time.Millisecond
190 c.Binds = map[string]string{}191 c.Binds = map[string]string{}
191 for _, b := range defaultBinds {192 for _, b := range defaultBinds {
192 combos := []string{b.combo}193 combos := []string{b.combo}
@@ -840,23 +841,35 @@ func set(c *ports.Config, key, v string) error {
840 return err841 return err
841 }842 }
842 c.Focus.FollowMove = b843 c.Focus.FollowMove = b
843- case "focus.animation":844+ case "focus.flash":
844- if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {845+ b, err := onOff(v)
845- return fmt.Errorf("must be pulse or off")846+ if err != nil {
846- }847+ return err
847- c.Focus.Animation = v848+ }
848- case "focus.effect":849+ c.Focus.Flash = b
849- if v != ports.FocusEffectScreen {850+ case "focus.flash-width":
850- return fmt.Errorf("must be screen")851+ n, err := strconv.Atoi(v)
851- }852+ if err != nil || n < 1 || n > 8 {
852- c.Focus.Effect = v853+ return fmt.Errorf("must be an integer between 1 and 8")
853- case "focus.strength":854+ }
854- // Capped low: the focus indicator must never flash.855+ c.Focus.FlashWidth = n
856+ case "focus.flash-opacity":
855 f, err := strconv.ParseFloat(v, 64)857 f, err := strconv.ParseFloat(v, 64)
856- if err != nil || !(f >= 0.01 && f <= 0.2) {858+ if err != nil || !(f >= 0.1 && f <= 1) {
857- return fmt.Errorf("must be between 0.01 and 0.2")859+ return fmt.Errorf("must be between 0.1 and 1")
860+ }
861+ c.Focus.FlashOpacity = f
862+ case "focus.flash-duration":
863+ d, err := time.ParseDuration(v)
864+ if err != nil || d < 50*time.Millisecond || d > time.Second {
865+ return fmt.Errorf("must be a duration between 50ms and 1s, e.g. 200ms")
866+ }
867+ c.Focus.FlashDuration = d
868+ case "focus.flash-color":
869+ if v != "" && !color.MatchString(v) {
870+ return fmt.Errorf("must be #rrggbb or empty (border.active)")
858 }871 }
859- c.Focus.Strength = f872+ c.Focus.FlashColor = v
860 case "render.direct-scanout":873 case "render.direct-scanout":
861 b, err := onOff(v)874 b, err := onOff(v)
862 if err != nil {875 if err != nil {
M internal/adapters/config/config_test.go +26 -7
@@ -110,21 +110,40 @@ func TestAnimations(t *testing.T) {
110110 }
111111 }
112112
113-func TestFocusIndicator(t *testing.T) {
113+func TestFocusFlash(t *testing.T) {
114114 d := Defaults().Focus
115- if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
116- t.Fatal("focus indicator defaults", d)
115+ if !d.Flash || d.FlashWidth != 2 || d.FlashOpacity != 0.6 || d.FlashDuration != 200*time.Millisecond || d.FlashColor != "" {
116+ t.Fatal("focus flash defaults", d)
117117 }
118- c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
119- if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
118+ c, w := parseString(t, "focus.flash = off\nfocus.flash-width = 8\nfocus.flash-opacity = 1\nfocus.flash-duration = 1s\nfocus.flash-color = #112233\n")
119+ want := d
120+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = false, 8, 1, time.Second, "#112233"
121+ if c.Focus != want || len(w) != 0 {
120122 t.Fatal(c.Focus, w)
121123 }
122- // Each rejected value warns and keeps its default.
123- for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
124+ c, w = parseString(t, "focus.flash = on\nfocus.flash-width = 1\nfocus.flash-opacity = 0.1\nfocus.flash-duration = 50ms\nfocus.flash-color =\n")
125+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = true, 1, 0.1, 50*time.Millisecond, ""
126+ if c.Focus != want || len(w) != 0 {
127+ t.Fatal(c.Focus, w)
128+ }
129+ // Each rejected value warns and keeps its default; removed keys are unknown.
130+ for _, line := range []string{
131+ "focus.flash = maybe",
132+ "focus.flash-width = 0", "focus.flash-width = 9", "focus.flash-width = x", "focus.flash-width = 1.5",
133+ "focus.flash-opacity = 0.05", "focus.flash-opacity = 1.1", "focus.flash-opacity = x",
134+ "focus.flash-duration = 10ms", "focus.flash-duration = 2s", "focus.flash-duration = x", "focus.flash-duration = 200",
135+ "focus.flash-color = red", "focus.flash-color = #12345",
136+ "focus.animation = pulse", "focus.effect = screen", "focus.strength = 0.04",
137+ } {
124138 c, w = parseString(t, line+"\n")
125139 if c.Focus != d || len(w) != 1 {
126140 t.Fatal(line, c.Focus, w)
127141 }
142+ if strings.HasPrefix(line, "focus.animation") || strings.HasPrefix(line, "focus.effect") || strings.HasPrefix(line, "focus.strength") {
143+ if !strings.Contains(w[0].Msg, "unknown key") {
144+ t.Fatal(line, w)
145+ }
146+ }
128147 }
129148 }
130149
@@ -110,21 +110,40 @@ func TestAnimations(t *testing.T) {
110 }110 }
111 }111 }
112 112
113-func TestFocusIndicator(t *testing.T) {113+func TestFocusFlash(t *testing.T) {
114 d := Defaults().Focus114 d := Defaults().Focus
115- if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {115+ if !d.Flash || d.FlashWidth != 2 || d.FlashOpacity != 0.6 || d.FlashDuration != 200*time.Millisecond || d.FlashColor != "" {
116- t.Fatal("focus indicator defaults", d)116+ t.Fatal("focus flash defaults", d)
117 }117 }
118- c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")118+ c, w := parseString(t, "focus.flash = off\nfocus.flash-width = 8\nfocus.flash-opacity = 1\nfocus.flash-duration = 1s\nfocus.flash-color = #112233\n")
119- if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {119+ want := d
120+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = false, 8, 1, time.Second, "#112233"
121+ if c.Focus != want || len(w) != 0 {
120 t.Fatal(c.Focus, w)122 t.Fatal(c.Focus, w)
121 }123 }
122- // Each rejected value warns and keeps its default.124+ c, w = parseString(t, "focus.flash = on\nfocus.flash-width = 1\nfocus.flash-opacity = 0.1\nfocus.flash-duration = 50ms\nfocus.flash-color =\n")
123- for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {125+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = true, 1, 0.1, 50*time.Millisecond, ""
126+ if c.Focus != want || len(w) != 0 {
127+ t.Fatal(c.Focus, w)
128+ }
129+ // Each rejected value warns and keeps its default; removed keys are unknown.
130+ for _, line := range []string{
131+ "focus.flash = maybe",
132+ "focus.flash-width = 0", "focus.flash-width = 9", "focus.flash-width = x", "focus.flash-width = 1.5",
133+ "focus.flash-opacity = 0.05", "focus.flash-opacity = 1.1", "focus.flash-opacity = x",
134+ "focus.flash-duration = 10ms", "focus.flash-duration = 2s", "focus.flash-duration = x", "focus.flash-duration = 200",
135+ "focus.flash-color = red", "focus.flash-color = #12345",
136+ "focus.animation = pulse", "focus.effect = screen", "focus.strength = 0.04",
137+ } {
124 c, w = parseString(t, line+"\n")138 c, w = parseString(t, line+"\n")
125 if c.Focus != d || len(w) != 1 {139 if c.Focus != d || len(w) != 1 {
126 t.Fatal(line, c.Focus, w)140 t.Fatal(line, c.Focus, w)
127 }141 }
142+ if strings.HasPrefix(line, "focus.animation") || strings.HasPrefix(line, "focus.effect") || strings.HasPrefix(line, "focus.strength") {
143+ if !strings.Contains(w[0].Msg, "unknown key") {
144+ t.Fatal(line, w)
145+ }
146+ }
128 }147 }
129 }148 }
130 149
M internal/adapters/drm/overlay.go +2 -2
@@ -93,9 +93,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9393 // buffer, and so is a colour the plane cannot show (raw PQ values
9494 // on an SDR output, SDR on HDR without a pipeline). A dimmed
9595 // window (a peeking stashed one) needs the veil drawn over it, a
96- // fading one its translucency, and one with the focus effect its
96+ // fading one its translucency.
9797 // lift; an overview preview is drawn smaller than its buffer.
98- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 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) && (kept == nil || !kept(w.ID)) {
98+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
9999 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
100100 pick, pickAt, mode = w, i, m
101101 continue
@@ -93,9 +93,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
93 // buffer, and so is a colour the plane cannot show (raw PQ values93 // buffer, and so is a colour the plane cannot show (raw PQ values
94 // on an SDR output, SDR on HDR without a pipeline). A dimmed94 // on an SDR output, SDR on HDR without a pipeline). A dimmed
95 // window (a peeking stashed one) needs the veil drawn over it, a95 // window (a peeking stashed one) needs the veil drawn over it, a
96- // fading one its translucency, and one with the focus effect its96+ // fading one its translucency.
97 // lift; an overview preview is drawn smaller than its buffer.97 // lift; an overview preview is drawn smaller than its buffer.
98- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 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) && (kept == nil || !kept(w.ID)) {98+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
99 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {99 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
100 pick, pickAt, mode = w, i, m100 pick, pickAt, mode = w, i, m
101 continue101 continue
M internal/adapters/drm/overlay_test.go +0 -6
@@ -156,12 +156,6 @@ func TestOverlayCandidate(t *testing.T) {
156156 if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
157157 t.Fatalf("zoomed window reason %q", reason)
158158 }
159- // A window with the focus effect needs it composed.
160- s, _ = overlayScene()
161- s.Windows[len(s.Windows)-1].FocusEffect = 0.05
162- if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
163- t.Fatalf("pulsing window reason %q", reason)
164- }
165159 // An overview preview is drawn smaller than its buffer: composed.
166160 s, _ = overlayScene()
167161 s.Windows[len(s.Windows)-1].Preview = 0.5
@@ -156,12 +156,6 @@ func TestOverlayCandidate(t *testing.T) {
156 if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {156 if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
157 t.Fatalf("zoomed window reason %q", reason)157 t.Fatalf("zoomed window reason %q", reason)
158 }158 }
159- // A window with the focus effect needs it composed.
160- s, _ = overlayScene()
161- s.Windows[len(s.Windows)-1].FocusEffect = 0.05
162- if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
163- t.Fatalf("pulsing window reason %q", reason)
164- }
165 // An overview preview is drawn smaller than its buffer: composed.159 // An overview preview is drawn smaller than its buffer: composed.
166 s, _ = overlayScene()160 s, _ = overlayScene()
167 s.Windows[len(s.Windows)-1].Preview = 0.5161 s.Windows[len(s.Windows)-1].Preview = 0.5
M internal/adapters/vulkan/damage.go +1 -0
@@ -276,6 +276,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
276276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
278278 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
279+ (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && *old.Flash != *cur.Flash) ||
279280 len(old.Windows) != len(cur.Windows) {
280281 return image.Rectangle{}, false
281282 }
@@ -276,6 +276,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
278 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||278 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
279+ (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && *old.Flash != *cur.Flash) ||
279 len(old.Windows) != len(cur.Windows) {280 len(old.Windows) != len(cur.Windows) {
280 return image.Rectangle{}, false281 return image.Rectangle{}, false
281 }282 }
M internal/adapters/vulkan/damage_delta_test.go +4 -4
@@ -17,9 +17,9 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 func TestSceneFieldCount(t *testing.T) {
20- // 22 with Animating, which sceneDelta intentionally ignores: it only
20+ // 23 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 22
22+ const fields = 23
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) {
2828 // sceneDelta compares windows by struct equality, so a new field is covered
2929 // as long as it only changes where or how that window draws (inside Rect).
3030 func TestSceneWindowFieldCount(t *testing.T) {
31- const fields = 15
31+ const fields = 14
3232 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
3333 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
3434 }
@@ -75,7 +75,7 @@ func TestDamageDeltaAtScale(t *testing.T) {
7575 func TestDamageDeltaWindowFieldChange(t *testing.T) {
7676 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
7777 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
78- cur.Windows[0].FocusEffect = 0.3
78+ cur.Windows[0].Dim = 0.3
7979 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
8080 require.False(t, d.all())
8181 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
@@ -17,9 +17,9 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18 // lists the fields that force a full redraw.18 // lists the fields that force a full redraw.
19 func TestSceneFieldCount(t *testing.T) {19 func TestSceneFieldCount(t *testing.T) {
20- // 22 with Animating, which sceneDelta intentionally ignores: it only20+ // 23 with Animating, which sceneDelta intentionally ignores: it only
21 // schedules the next frame, it draws nothing.21 // schedules the next frame, it draws nothing.
22- const fields = 2222+ const fields = 23
23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
25 }25 }
@@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) {
28 // sceneDelta compares windows by struct equality, so a new field is covered28 // sceneDelta compares windows by struct equality, so a new field is covered
29 // as long as it only changes where or how that window draws (inside Rect).29 // as long as it only changes where or how that window draws (inside Rect).
30 func TestSceneWindowFieldCount(t *testing.T) {30 func TestSceneWindowFieldCount(t *testing.T) {
31- const fields = 1531+ const fields = 14
32 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),32 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
33 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")33 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
34 }34 }
@@ -75,7 +75,7 @@ func TestDamageDeltaAtScale(t *testing.T) {
75 func TestDamageDeltaWindowFieldChange(t *testing.T) {75 func TestDamageDeltaWindowFieldChange(t *testing.T) {
76 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))76 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
77 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))77 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
78- cur.Windows[0].FocusEffect = 0.378+ cur.Windows[0].Dim = 0.3
79 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))79 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
80 require.False(t, d.all())80 require.False(t, d.all())
81 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)81 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
M internal/adapters/vulkan/hdr_composition_test.go +0 -19
@@ -259,25 +259,6 @@ func TestHDRLinearBlend(t *testing.T) {
259259 }
260260 }
261261
262-// The focus pulse on an HDR output applies the SDR lift to sRGB content
263-// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
264-// separates this from a lift in linear light or none at all.
265-func TestHDRPulse(t *testing.T) {
266- r := hdrTestRenderer(t)
267- c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
268- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
269- if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
270- t.Fatal(err)
271- }
272- got := hdrTargetAt(t, r, 5, 5)
273- want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
274- for i, v := range got {
275- if math.Abs(v-want) > .006 {
276- t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
277- }
278- }
279-}
280-
281262 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
282263 r := hdrTestRenderer(t)
283264 const background = "#804020"
@@ -259,25 +259,6 @@ func TestHDRLinearBlend(t *testing.T) {
259 }259 }
260 }260 }
261 261
262-// The focus pulse on an HDR output applies the SDR lift to sRGB content
263-// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
264-// separates this from a lift in linear light or none at all.
265-func TestHDRPulse(t *testing.T) {
266- r := hdrTestRenderer(t)
267- c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
268- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
269- if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
270- t.Fatal(err)
271- }
272- got := hdrTargetAt(t, r, 5, 5)
273- want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
274- for i, v := range got {
275- if math.Abs(v-want) > .006 {
276- t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
277- }
278- }
279-}
280-
281 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {262 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
282 r := hdrTestRenderer(t)263 r := hdrTestRenderer(t)
283 const background = "#804020"264 const background = "#804020"
M internal/adapters/vulkan/renderer_test.go +0 -24
@@ -111,30 +111,6 @@ func TestRendererFade(t *testing.T) {
111111 }
112112 }
113113
114-// A pulsing window lifts toward white: black the most, white not at all.
115-func TestRendererPulse(t *testing.T) {
116- r, err := New(64, 48)
117- if err != nil {
118- t.Skipf("Vulkan unavailable: %v", err)
119- }
120- defer r.Close()
121- for i, tc := range []struct {
122- in, want uint8
123- }{{0, 26}, {64, 83}, {200, 206}, {255, 255}} {
124- // One window per case: the shm copy is cached per window.
125- id := ports.WindowID(i + 1)
126- c := solidContent(t, 64, 48, color.RGBA{tc.in, tc.in, tc.in, 255})
127- s := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: id, Rect: ports.Rect{W: 64, H: 48}, FocusEffect: 0.1}}}
128- if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{id: c}); err != nil {
129- t.Fatal(err)
130- }
131- got := readPixels(t, r).At(10, 10).(color.RGBA)
132- if d := int(got.R) - int(tc.want); d < -1 || d > 1 || got.R != got.G || got.G != got.B {
133- t.Errorf("pulse of %d = %v, want %d", tc.in, got, tc.want)
134- }
135- }
136-}
137-
138114 func TestRendererContents(t *testing.T) {
139115 r, err := New(64, 48)
140116 if err != nil {
@@ -111,30 +111,6 @@ 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-
138 func TestRendererContents(t *testing.T) {114 func TestRendererContents(t *testing.T) {
139 r, err := New(64, 48)115 r, err := New(64, 48)
140 if err != nil {116 if err != nil {
M internal/adapters/vulkan/scene.go +1 -7
@@ -20,9 +20,6 @@ type sceneWalk struct {
2020 // zoom shrinks the surfaces of the window being placed: an overview
2121 // preview (ports.SceneWindow.Preview); 1 otherwise.
2222 zoom float64
23- // pulse is the focus effect of the window being placed
24- // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float32
2623 // alpha is the opacity of the window being drawn, 1 - its Fade
2724 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
2825 // it (premultiplied); 1 otherwise.
@@ -251,9 +248,8 @@ func (w *sceneWalk) windows() {
251248 } else if win.Preview > 0 {
252249 w.zoom = win.Preview
253250 }
254- w.pulse = float32(max(0, min(win.FocusEffect, 1)))
255251 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
256- w.zoom, w.pulse = 1, 0
252+ w.zoom = 1
257253 }
258254 if win.Floating {
259255 w.separators(win.ID)
@@ -543,7 +539,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
543539 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
544540 r.setContentColor(&dr, content)
545541 dr.pc.color[3] *= w.alpha
546- dr.pc.mapy[2] = w.pulse
547542 if im.yuv {
548543 dr.pc.misc[1] |= flagYUV
549544 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
@@ -590,7 +585,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
590585 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
591586 r.setContentColor(&dr, content)
592587 dr.pc.color[3] *= w.alpha
593- dr.pc.mapy[2] = w.pulse
594588 dr.shm = c
595589 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
596590 w.draws = append(w.draws, dr)
@@ -20,9 +20,6 @@ type sceneWalk struct {
20 // zoom shrinks the surfaces of the window being placed: an overview20 // zoom shrinks the surfaces of the window being placed: an overview
21 // preview (ports.SceneWindow.Preview); 1 otherwise.21 // preview (ports.SceneWindow.Preview); 1 otherwise.
22 zoom float6422 zoom float64
23- // pulse is the focus effect of the window being placed
24- // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float32
26 // alpha is the opacity of the window being drawn, 1 - its Fade23 // alpha is the opacity of the window being drawn, 1 - its Fade
27 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by24 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
28 // it (premultiplied); 1 otherwise.25 // it (premultiplied); 1 otherwise.
@@ -251,9 +248,8 @@ func (w *sceneWalk) windows() {
251 } else if win.Preview > 0 {248 } else if win.Preview > 0 {
252 w.zoom = win.Preview249 w.zoom = win.Preview
253 }250 }
254- w.pulse = float32(max(0, min(win.FocusEffect, 1)))
255 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)251 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
256- w.zoom, w.pulse = 1, 0252+ w.zoom = 1
257 }253 }
258 if win.Floating {254 if win.Floating {
259 w.separators(win.ID)255 w.separators(win.ID)
@@ -543,7 +539,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
543 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)539 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
544 r.setContentColor(&dr, content)540 r.setContentColor(&dr, content)
545 dr.pc.color[3] *= w.alpha541 dr.pc.color[3] *= w.alpha
546- dr.pc.mapy[2] = w.pulse
547 if im.yuv {542 if im.yuv {
548 dr.pc.misc[1] |= flagYUV543 dr.pc.misc[1] |= flagYUV
549 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {544 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
@@ -590,7 +585,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
590 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)585 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
591 r.setContentColor(&dr, content)586 r.setContentColor(&dr, content)
592 dr.pc.color[3] *= w.alpha587 dr.pc.color[3] *= w.alpha
593- dr.pc.mapy[2] = w.pulse
594 dr.shm = c588 dr.shm = c
595 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}589 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
596 w.draws = append(w.draws, dr)590 w.draws = append(w.draws, dr)
M internal/adapters/vulkan/shaders/compose.frag +0 -6
@@ -146,12 +146,6 @@ void main() {
146146 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
147147 }
148148 }
149- if (d.mapy.z > 0.0) {
150- // Focus pulse: a screen-like lift toward white, on premultiplied
151- // values (rgb/a + k * (1 - rgb/a)) * a. Black shows it most; white
152- // never saturates.
153- c.rgb += d.mapy.z * (c.a - c.rgb);
154- }
155149 if (d.misc.x != modeSolid) {
156150 // The surface's alpha modifier; premultiplied, it scales all four.
157151 c *= d.color.a;
@@ -146,12 +146,6 @@ void main() {
146 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;146 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
147 }147 }
148 }148 }
149- if (d.mapy.z > 0.0) {
150- // Focus pulse: a screen-like lift toward white, on premultiplied
151- // values (rgb/a + k * (1 - rgb/a)) * a. Black shows it most; white
152- // never saturates.
153- c.rgb += d.mapy.z * (c.a - c.rgb);
154- }
155 if (d.misc.x != modeSolid) {149 if (d.misc.x != modeSolid) {
156 // The surface's alpha modifier; premultiplied, it scales all four.150 // The surface's alpha modifier; premultiplied, it scales all four.
157 c *= d.color.a;151 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
@@ -5,7 +5,7 @@
55 struct Draw {
66 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
77 vec4 map; // source texel = map.xy + fragment centre.x * map.zw
8- vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z is the focus pulse
8+ vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z and w are unused
99 vec4 color; // solid premultiplied RGBA; content opacity in alpha
1010 vec4 crop; // source crop bounds in buffer pixels
1111 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w
@@ -5,7 +5,7 @@
5 struct Draw {5 struct Draw {
6 ivec4 rect; // visible x0, y0, x1, y1 in target pixels6 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
7 vec4 map; // source texel = map.xy + fragment centre.x * map.zw7 vec4 map; // source texel = map.xy + fragment centre.x * map.zw
8- vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z is the focus pulse8+ vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z and w are unused
9 vec4 color; // solid premultiplied RGBA; content opacity in alpha9 vec4 color; // solid premultiplied RGBA; content opacity in alpha
10 vec4 crop; // source crop bounds in buffer pixels10 vec4 crop; // source crop bounds in buffer pixels
11 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w11 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w
M internal/adapters/vulkan/shaders/compose_hdr.frag +0 -6
@@ -141,12 +141,6 @@ void main() {
141141 // ext_linear has the protocol's default 80 cd/m² reference white.
142142 // Keep negative and above-white values in fp16 until the HDR pass.
143143 vec3 v = c.rgb / c.a;
144- bool srgb = (d.misc.y & (flagPQ | flagExtendedLinear)) == 0u;
145- if (srgb && d.mapy.z > 0.0) {
146- // Focus pulse: the SDR lift, on sRGB-encoded values before
147- // decoding, so it looks the same as on an SDR output.
148- v += d.mapy.z * (1.0 - v);
149- }
150144 vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :
151145 (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);
152146 c.rgb = linearRGB * c.a;
@@ -141,12 +141,6 @@ void main() {
141 // ext_linear has the protocol's default 80 cd/m² reference white.141 // ext_linear has the protocol's default 80 cd/m² reference white.
142 // Keep negative and above-white values in fp16 until the HDR pass.142 // Keep negative and above-white values in fp16 until the HDR pass.
143 vec3 v = c.rgb / c.a;143 vec3 v = c.rgb / c.a;
144- bool srgb = (d.misc.y & (flagPQ | flagExtendedLinear)) == 0u;
145- if (srgb && d.mapy.z > 0.0) {
146- // Focus pulse: the SDR lift, on sRGB-encoded values before
147- // decoding, so it looks the same as on an SDR output.
148- v += d.mapy.z * (1.0 - v);
149- }
150 vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :144 vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :
151 (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);145 (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);
152 c.rgb = linearRGB * c.a;146 c.rgb = linearRGB * c.a;
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_hdr_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/app/app.go +0 -4
@@ -176,10 +176,6 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
176176 rects := make([]string, 0, len(s.Windows))
177177 for _, w := range s.Windows {
178178 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179- if w.FocusEffect > 0 {
180- // The focus indicator frame by frame: window and effect.
181- ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182- }
183179 }
184180 ev.Strs("windows", rects).Msg("scene")
185181 }
@@ -176,10 +176,6 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
176 rects := make([]string, 0, len(s.Windows))176 rects := make([]string, 0, len(s.Windows))
177 for _, w := range s.Windows {177 for _, w := range s.Windows {
178 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))178 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179- if w.FocusEffect > 0 {
180- // The focus indicator frame by frame: window and effect.
181- ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182- }
183 }179 }
184 ev.Strs("windows", rects).Msg("scene")180 ev.Strs("windows", rects).Msg("scene")
185 }181 }
M internal/app/app_test.go +0 -2
@@ -243,8 +243,6 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
243243 t.Setenv("XDG_STATE_HOME", t.TempDir())
244244 cfg := config.Defaults()
245245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
246- // Pulse frames would fill the scene tap before the click.
247- cfg.Focus.Animation = ports.FocusAnimationOff
248246 cfg.Animations.On = animated
249247 // The tap drops a scene it has no room for: a collector keeps up with
250248 // the animation frames.
@@ -243,8 +243,6 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
243 t.Setenv("XDG_STATE_HOME", t.TempDir())243 t.Setenv("XDG_STATE_HOME", t.TempDir())
244 cfg := config.Defaults()244 cfg := config.Defaults()
245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
246- // Pulse frames would fill the scene tap before the click.
247- cfg.Focus.Animation = ports.FocusAnimationOff
248 cfg.Animations.On = animated246 cfg.Animations.On = animated
249 // The tap drops a scene it has no room for: a collector keeps up with247 // The tap drops a scene it has no room for: a collector keeps up with
250 // the animation frames.248 // the animation frames.
M internal/app/scenelog_test.go +2 -2
@@ -13,7 +13,7 @@ func logTestScenes() []ports.Scene {
1313 Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,
1414 Windows: []ports.SceneWindow{
1515 {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},
16- {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}, FocusEffect: 0.5},
16+ {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}},
1717 },
1818 }, {Output: "B"}}
1919 }
@@ -40,7 +40,7 @@ func TestLogScenesDebug(t *testing.T) {
4040 for _, want := range []string{
4141 `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,
4242 `"windows":["1:10,20 300x200","2:5,6 7x8"]`,
43- `"focus_effect_window":2`, `"focus_effect":0.5`, `"message":"scene"`,
43+ `"message":"scene"`,
4444 } {
4545 if !strings.Contains(got, want) {
4646 t.Fatalf("debug log lacks %s: %s", want, got)
@@ -13,7 +13,7 @@ func logTestScenes() []ports.Scene {
13 Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,13 Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,
14 Windows: []ports.SceneWindow{14 Windows: []ports.SceneWindow{
15 {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},15 {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},
16- {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}, FocusEffect: 0.5},16+ {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}},
17 },17 },
18 }, {Output: "B"}}18 }, {Output: "B"}}
19 }19 }
@@ -40,7 +40,7 @@ func TestLogScenesDebug(t *testing.T) {
40 for _, want := range []string{40 for _, want := range []string{
41 `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,41 `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,
42 `"windows":["1:10,20 300x200","2:5,6 7x8"]`,42 `"windows":["1:10,20 300x200","2:5,6 7x8"]`,
43- `"focus_effect_window":2`, `"focus_effect":0.5`, `"message":"scene"`,43+ `"message":"scene"`,
44 } {44 } {
45 if !strings.Contains(got, want) {45 if !strings.Contains(got, want) {
46 t.Fatalf("debug log lacks %s: %s", want, got)46 t.Fatalf("debug log lacks %s: %s", want, got)
M internal/core/core.go +5 -52
@@ -156,8 +156,6 @@ type Core struct {
156156 pointerAt [2]float64
157157 // capt is the capture state (capture.go, capindicator.go).
158158 capt captureState
159- // pulse marks a window that just got the focus (pulse.go).
160- pulse focusPulse
161159 // switcher is the column switcher in progress: the screen it is on and
162160 // the timer that shows its cards (switcher.go).
163161 switcher struct {
@@ -532,14 +530,6 @@ func (v viewport) area(p Placement) Rect {
532530 return v.usable
533531 }
534532
535-// hasRoom reports whether p's area can show anything: panels can leave
536-// tiles no room at all. Unlike shows, it ignores where p is, so a window
537-// still sliding in counts.
538-func hasRoom(v viewport, p Placement) bool {
539- a := v.area(p)
540- return a.W > 0 && a.H > 0
541-}
542-
543533 // shows reports whether the user sees p: not hidden, and overlapping its
544534 // area.
545535 func (v viewport) shows(p Placement) bool {
@@ -636,9 +626,8 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
636626 func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }
637627
638628 // publishFrame publishes the scenes. only is the screen whose page flip
639-// moved the springs (nil: any, or none moved): the pulse advances with the
640-// focused screen's flip, and only a flip's own screen gets a fresh Seq for
641-// its animation, so another output keeps its Seq while it draws the same.
629+// moved the springs (nil: any, or none moved): only a flip's own screen gets
630+// a fresh Seq for its animation, so another output keeps its Seq while it draws the same.
642631 func (c *Core) publishFrame(ctx context.Context, only *screen) error {
643632 if c.inputEpochChanged() {
644633 return errSecurityChanged
@@ -685,44 +674,17 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
685674 return err
686675 }
687676 focus := c.keyboardFocus()
688- window, _ := c.cur().mon.Focused()
689- c.pulseFocus(window, focus == window)
690- // The pulse runs on the focused output: a flip of another output reuses
691- // its value so the focused scene keeps its Seq.
692- if c.pulse.id == 0 {
693- c.pulse.value = 0
694- } else if only == nil || only == c.cur() {
695- c.pulse.value = c.advancePulse(c.now())
696- }
697- pulse := c.pulse.value
698- // drawable: the scene can show a pulse on the focused window.
699- drawable := false
700677 scenes := make([]ports.Scene, 0, len(c.screens))
701678 // Before the first output (and after the last is unplugged) the
702679 // placeholder's scene has no output name: no renderer draws it.
703680 for i, sc := range c.screens {
704- scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse)
681+ scene, err := c.sceneFor(ctx, i, sc, only, capture)
705682 if err != nil {
706683 return err
707684 }
708- drawable = drawable || shown
709685 scenes = append(scenes, scene)
710686 }
711687 c.configures.prune()
712- // A pulse nothing shows (fullscreen, overview) asks for no frames.
713- if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 {
714- c.pulse.id = 0
715- // The focused scene was built as animating: publish it settled,
716- // or its flips would keep waking core. A scene that kept its Seq
717- // needs a fresh one, or outputs keep the animating one they show.
718- if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() {
719- scenes[i].Animating = false
720- if scenes[i].Seq == c.cur().last.Seq {
721- c.seq++
722- scenes[i].Seq = c.seq
723- }
724- }
725- }
726688 for i, sc := range c.screens {
727689 sc.last = scenes[i]
728690 }
@@ -776,11 +738,11 @@ func (c *Core) step(ctx context.Context, only *screen) error {
776738 return c.slidFrame(ctx, false, only)
777739 }
778740
779-// animatingOther reports whether a spring or the pulse runs on a screen
741+// animatingOther reports whether a spring runs on a screen
780742 // other than only.
781743 func (c *Core) animatingOther(only *screen) bool {
782744 for _, sc := range c.screens {
783- if sc != only && (sc.springing() || c.pulsing(sc)) {
745+ if sc != only && sc.springing() {
784746 return true
785747 }
786748 }
@@ -1128,7 +1090,6 @@ func (c *Core) Run(ctx context.Context) error {
11281090 c.syncSecurity()
11291091 defer c.stopFrame()
11301092 defer c.stopCaptureTimer()
1131- defer c.stopPulseTimer()
11321093 defer c.cancelSwitcher()
11331094 defer c.stopRepeat()
11341095 // Startup commands run once per session. Full launcher queues retain a
@@ -1198,14 +1159,6 @@ func (c *Core) Run(ctx context.Context) error {
11981159 return nil
11991160 }
12001161 continue
1201- case <-c.pulse.timerC:
1202- if c.securityCheckpoint(ctx) != nil {
1203- return nil
1204- }
1205- if c.pulseTick() && c.publish(ctx) != nil {
1206- return nil
1207- }
1208- continue
12091162 case <-c.switcher.timerC:
12101163 if c.securityCheckpoint(ctx) != nil {
12111164 return nil
@@ -156,8 +156,6 @@ type Core struct {
156 pointerAt [2]float64156 pointerAt [2]float64
157 // capt is the capture state (capture.go, capindicator.go).157 // capt is the capture state (capture.go, capindicator.go).
158 capt captureState158 capt captureState
159- // pulse marks a window that just got the focus (pulse.go).
160- pulse focusPulse
161 // switcher is the column switcher in progress: the screen it is on and159 // switcher is the column switcher in progress: the screen it is on and
162 // the timer that shows its cards (switcher.go).160 // the timer that shows its cards (switcher.go).
163 switcher struct {161 switcher struct {
@@ -532,14 +530,6 @@ func (v viewport) area(p Placement) Rect {
532 return v.usable530 return v.usable
533 }531 }
534 532
535-// hasRoom reports whether p's area can show anything: panels can leave
536-// tiles no room at all. Unlike shows, it ignores where p is, so a window
537-// still sliding in counts.
538-func hasRoom(v viewport, p Placement) bool {
539- a := v.area(p)
540- return a.W > 0 && a.H > 0
541-}
542-
543 // shows reports whether the user sees p: not hidden, and overlapping its533 // shows reports whether the user sees p: not hidden, and overlapping its
544 // area.534 // area.
545 func (v viewport) shows(p Placement) bool {535 func (v viewport) shows(p Placement) bool {
@@ -636,9 +626,8 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
636 func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }626 func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }
637 627
638 // publishFrame publishes the scenes. only is the screen whose page flip628 // publishFrame publishes the scenes. only is the screen whose page flip
639-// moved the springs (nil: any, or none moved): the pulse advances with the629+// moved the springs (nil: any, or none moved): only a flip's own screen gets
640-// focused screen's flip, and only a flip's own screen gets a fresh Seq for630+// a fresh Seq for its animation, so another output keeps its Seq while it draws the same.
641-// its animation, so another output keeps its Seq while it draws the same.
642 func (c *Core) publishFrame(ctx context.Context, only *screen) error {631 func (c *Core) publishFrame(ctx context.Context, only *screen) error {
643 if c.inputEpochChanged() {632 if c.inputEpochChanged() {
644 return errSecurityChanged633 return errSecurityChanged
@@ -685,44 +674,17 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
685 return err674 return err
686 }675 }
687 focus := c.keyboardFocus()676 focus := c.keyboardFocus()
688- window, _ := c.cur().mon.Focused()
689- c.pulseFocus(window, focus == window)
690- // The pulse runs on the focused output: a flip of another output reuses
691- // its value so the focused scene keeps its Seq.
692- if c.pulse.id == 0 {
693- c.pulse.value = 0
694- } else if only == nil || only == c.cur() {
695- c.pulse.value = c.advancePulse(c.now())
696- }
697- pulse := c.pulse.value
698- // drawable: the scene can show a pulse on the focused window.
699- drawable := false
700 scenes := make([]ports.Scene, 0, len(c.screens))677 scenes := make([]ports.Scene, 0, len(c.screens))
701 // Before the first output (and after the last is unplugged) the678 // Before the first output (and after the last is unplugged) the
702 // placeholder's scene has no output name: no renderer draws it.679 // placeholder's scene has no output name: no renderer draws it.
703 for i, sc := range c.screens {680 for i, sc := range c.screens {
704- scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse)681+ scene, err := c.sceneFor(ctx, i, sc, only, capture)
705 if err != nil {682 if err != nil {
706 return err683 return err
707 }684 }
708- drawable = drawable || shown
709 scenes = append(scenes, scene)685 scenes = append(scenes, scene)
710 }686 }
711 c.configures.prune()687 c.configures.prune()
712- // A pulse nothing shows (fullscreen, overview) asks for no frames.
713- if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 {
714- c.pulse.id = 0
715- // The focused scene was built as animating: publish it settled,
716- // or its flips would keep waking core. A scene that kept its Seq
717- // needs a fresh one, or outputs keep the animating one they show.
718- if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() {
719- scenes[i].Animating = false
720- if scenes[i].Seq == c.cur().last.Seq {
721- c.seq++
722- scenes[i].Seq = c.seq
723- }
724- }
725- }
726 for i, sc := range c.screens {688 for i, sc := range c.screens {
727 sc.last = scenes[i]689 sc.last = scenes[i]
728 }690 }
@@ -776,11 +738,11 @@ func (c *Core) step(ctx context.Context, only *screen) error {
776 return c.slidFrame(ctx, false, only)738 return c.slidFrame(ctx, false, only)
777 }739 }
778 740
779-// animatingOther reports whether a spring or the pulse runs on a screen741+// animatingOther reports whether a spring runs on a screen
780 // other than only.742 // other than only.
781 func (c *Core) animatingOther(only *screen) bool {743 func (c *Core) animatingOther(only *screen) bool {
782 for _, sc := range c.screens {744 for _, sc := range c.screens {
783- if sc != only && (sc.springing() || c.pulsing(sc)) {745+ if sc != only && sc.springing() {
784 return true746 return true
785 }747 }
786 }748 }
@@ -1128,7 +1090,6 @@ func (c *Core) Run(ctx context.Context) error {
1128 c.syncSecurity()1090 c.syncSecurity()
1129 defer c.stopFrame()1091 defer c.stopFrame()
1130 defer c.stopCaptureTimer()1092 defer c.stopCaptureTimer()
1131- defer c.stopPulseTimer()
1132 defer c.cancelSwitcher()1093 defer c.cancelSwitcher()
1133 defer c.stopRepeat()1094 defer c.stopRepeat()
1134 // Startup commands run once per session. Full launcher queues retain a1095 // Startup commands run once per session. Full launcher queues retain a
@@ -1198,14 +1159,6 @@ func (c *Core) Run(ctx context.Context) error {
1198 return nil1159 return nil
1199 }1160 }
1200 continue1161 continue
1201- case <-c.pulse.timerC:
1202- if c.securityCheckpoint(ctx) != nil {
1203- return nil
1204- }
1205- if c.pulseTick() && c.publish(ctx) != nil {
1206- return nil
1207- }
1208- continue
1209 case <-c.switcher.timerC:1162 case <-c.switcher.timerC:
1210 if c.securityCheckpoint(ctx) != nil {1163 if c.securityCheckpoint(ctx) != nil {
1211 return nil1164 return nil
M internal/core/core_test.go +0 -2
@@ -973,7 +973,6 @@ func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored)
973973 func fullscreenAtMapIgnored(t *testing.T, animated bool) {
974974 cfg := scrollDefaults()
975975 cfg.Layout.Overflow = "fixed"
976- cfg.Focus.Animation = ports.FocusAnimationOff
977976 client := make(chan ports.ClientEvent, 8)
978977 output := make(chan ports.OutputEvent, 8)
979978 commands := make(chan ports.ClientCommand, 64)
@@ -1063,7 +1062,6 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscre
10631062
10641063 func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
10651064 cfg := scrollDefaults()
1066- cfg.Focus.Animation = ports.FocusAnimationOff
10671065 client := make(chan ports.ClientEvent, 8)
10681066 output := make(chan ports.OutputEvent, 8)
10691067 commands := make(chan ports.ClientCommand, 64)
@@ -973,7 +973,6 @@ func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored)
973 func fullscreenAtMapIgnored(t *testing.T, animated bool) {973 func fullscreenAtMapIgnored(t *testing.T, animated bool) {
974 cfg := scrollDefaults()974 cfg := scrollDefaults()
975 cfg.Layout.Overflow = "fixed"975 cfg.Layout.Overflow = "fixed"
976- cfg.Focus.Animation = ports.FocusAnimationOff
977 client := make(chan ports.ClientEvent, 8)976 client := make(chan ports.ClientEvent, 8)
978 output := make(chan ports.OutputEvent, 8)977 output := make(chan ports.OutputEvent, 8)
979 commands := make(chan ports.ClientCommand, 64)978 commands := make(chan ports.ClientCommand, 64)
@@ -1063,7 +1062,6 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscre
1063 1062
1064 func externalFullscreenAtMapApplies(t *testing.T, animated bool) {1063 func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
1065 cfg := scrollDefaults()1064 cfg := scrollDefaults()
1066- cfg.Focus.Animation = ports.FocusAnimationOff
1067 client := make(chan ports.ClientEvent, 8)1065 client := make(chan ports.ClientEvent, 8)
1068 output := make(chan ports.OutputEvent, 8)1066 output := make(chan ports.OutputEvent, 8)
1069 commands := make(chan ports.ClientCommand, 64)1067 commands := make(chan ports.ClientCommand, 64)
D internal/core/frame_pulse_test.go +0 -69
@@ -1,69 +0,0 @@
1-package core
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/bnema/neferwl/internal/ports"
9-)
10-
11-// stepScenes steps only the screen and returns the scene of each output.
12-func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13- t.Helper()
14- if err := c.step(context.Background(), only); err != nil {
15- t.Fatal(err)
16- }
17- out := map[string]ports.Scene{}
18- for _, s := range <-c.ch.Scenes {
19- out[s.Output] = s
20- }
21- return out
22-}
23-
24-// While the focus pulse runs on the focused output, a flip of another output
25-// keeps the focused output's Seq; its own flip samples the pulse.
26-func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27- c, ic := pulseCore(t)
28- a := c.cur()
29- c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30- b := c.screens[1]
31- for id := WindowID(3); id <= 5; id++ {
32- b.mon.AddWindow(id)
33- }
34- indicatorScene(t, c)
35-
36- // A pulse on window 1 of A.
37- a.mon.Current().FocusID(1)
38- indicatorScene(t, c)
39- ic.now = ic.now.Add(pulseSettle)
40- if !c.pulseTick() {
41- t.Fatal("no pulse after settle")
42- }
43- // A camera transition runs on B.
44- w := b.mon.Current()
45- w.view.off = 100
46- w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
47-
48- ic.now = ic.now.Add(pulseRise / 2)
49- first := stepScenes(t, c, a)
50- if pulseOf(first["A"], 1) <= 0 {
51- t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52- }
53- ic.now = ic.now.Add(10 * time.Millisecond)
54- second := stepScenes(t, c, b)
55- if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56- t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57- }
58- if second["B"].SameAs(first["B"]) {
59- t.Fatal("B did not move on its own flip")
60- }
61- ic.now = ic.now.Add(10 * time.Millisecond)
62- third := stepScenes(t, c, a)
63- if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64- t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65- }
66- if third["B"].Seq != second["B"].Seq {
67- t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68- }
69-}
@@ -1,69 +0,0 @@
1-package core
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/bnema/neferwl/internal/ports"
9-)
10-
11-// stepScenes steps only the screen and returns the scene of each output.
12-func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13- t.Helper()
14- if err := c.step(context.Background(), only); err != nil {
15- t.Fatal(err)
16- }
17- out := map[string]ports.Scene{}
18- for _, s := range <-c.ch.Scenes {
19- out[s.Output] = s
20- }
21- return out
22-}
23-
24-// While the focus pulse runs on the focused output, a flip of another output
25-// keeps the focused output's Seq; its own flip samples the pulse.
26-func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27- c, ic := pulseCore(t)
28- a := c.cur()
29- c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30- b := c.screens[1]
31- for id := WindowID(3); id <= 5; id++ {
32- b.mon.AddWindow(id)
33- }
34- indicatorScene(t, c)
35-
36- // A pulse on window 1 of A.
37- a.mon.Current().FocusID(1)
38- indicatorScene(t, c)
39- ic.now = ic.now.Add(pulseSettle)
40- if !c.pulseTick() {
41- t.Fatal("no pulse after settle")
42- }
43- // A camera transition runs on B.
44- w := b.mon.Current()
45- w.view.off = 100
46- w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
47-
48- ic.now = ic.now.Add(pulseRise / 2)
49- first := stepScenes(t, c, a)
50- if pulseOf(first["A"], 1) <= 0 {
51- t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52- }
53- ic.now = ic.now.Add(10 * time.Millisecond)
54- second := stepScenes(t, c, b)
55- if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56- t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57- }
58- if second["B"].SameAs(first["B"]) {
59- t.Fatal("B did not move on its own flip")
60- }
61- ic.now = ic.now.Add(10 * time.Millisecond)
62- third := stepScenes(t, c, a)
63- if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64- t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65- }
66- if third["B"].Seq != second["B"].Seq {
67- t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68- }
69-}
M internal/core/gesture.go +3 -6
@@ -577,14 +577,11 @@ func (c *Core) animate(now time.Time, only *screen) {
577577 // sliding reports whether a spring runs on the named output.
578578 func (c *Core) sliding(output string) bool {
579579 i := c.screenIndex(output)
580- return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
580+ return i >= 0 && c.screens[i].springing()
581581 }
582582
583-// animating reports whether a spring or the focus pulse runs on any output.
583+// animating reports whether a spring runs on any output.
584584 func (c *Core) animating() bool {
585- if c.pulse.id != 0 {
586- return true
587- }
588585 for _, sc := range c.screens {
589586 if sc.springing() {
590587 return true
@@ -610,7 +607,7 @@ func (m *Monitor) springing() bool {
610607 func (c *Core) frameFallback() time.Duration {
611608 refresh := 60000
612609 for _, sc := range c.screens {
613- if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
610+ if r := sc.info.RefreshMilli; r > 0 && sc.springing() {
614611 refresh = min(refresh, r)
615612 }
616613 }
@@ -577,14 +577,11 @@ func (c *Core) animate(now time.Time, only *screen) {
577 // sliding reports whether a spring runs on the named output.577 // sliding reports whether a spring runs on the named output.
578 func (c *Core) sliding(output string) bool {578 func (c *Core) sliding(output string) bool {
579 i := c.screenIndex(output)579 i := c.screenIndex(output)
580- return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))580+ return i >= 0 && c.screens[i].springing()
581 }581 }
582 582
583-// animating reports whether a spring or the focus pulse runs on any output.583+// animating reports whether a spring runs on any output.
584 func (c *Core) animating() bool {584 func (c *Core) animating() bool {
585- if c.pulse.id != 0 {
586- return true
587- }
588 for _, sc := range c.screens {585 for _, sc := range c.screens {
589 if sc.springing() {586 if sc.springing() {
590 return true587 return true
@@ -610,7 +607,7 @@ func (m *Monitor) springing() bool {
610 func (c *Core) frameFallback() time.Duration {607 func (c *Core) frameFallback() time.Duration {
611 refresh := 60000608 refresh := 60000
612 for _, sc := range c.screens {609 for _, sc := range c.screens {
613- if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {610+ if r := sc.info.RefreshMilli; r > 0 && sc.springing() {
614 refresh = min(refresh, r)611 refresh = min(refresh, r)
615 }612 }
616 }613 }
M internal/core/persist_test.go +1 -1
@@ -22,7 +22,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
2222 now = now.Add(time.Second)
2323 return now
2424 }).Maybe()
25- // Timers (the focus pulse) never fire: tests drive events only.
25+ // Timers never fire: tests drive events only.
2626 clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
2727 timer := portsmocks.NewMockTimer(t)
2828 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
@@ -22,7 +22,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
22 now = now.Add(time.Second)22 now = now.Add(time.Second)
23 return now23 return now
24 }).Maybe()24 }).Maybe()
25- // Timers (the focus pulse) never fire: tests drive events only.25+ // Timers never fire: tests drive events only.
26 clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {26 clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
27 timer := portsmocks.NewMockTimer(t)27 timer := portsmocks.NewMockTimer(t)
28 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()28 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
D internal/core/pulse.go +0 -183
@@ -1,183 +0,0 @@
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, a window alone
18-// in the usable area (e.g. a maximized column), overview previews, a window focused as
19-// it maps and a protected session never pulse.
20-
21-const (
22- pulseSettle = 150 * time.Millisecond
23- pulseRise = 90 * time.Millisecond
24- pulseFall = 230 * time.Millisecond
25- pulseCooldown = time.Second
26- // pulseRecheck is how often a pulse waiting for a slide to land checks again.
27- pulseRecheck = pulseSettle / 3
28-)
29-
30-// focusPulse is the pulse state. target is the window focus last seen and
31-// since when; id is the window pulsing from start (0: none). last and lastAt
32-// are the previous pulse, for the cooldown. drawable is whether the last
33-// scene could show a pulse on target.
34-type focusPulse struct {
35- target WindowID
36- since time.Time
37- id WindowID
38- start time.Time
39- last WindowID
40- lastAt time.Time
41- drawable bool
42- // value is the effect of the last sample, kept for scenes published
43- // between the focused output's own flips.
44- value float64
45- timerC <-chan time.Time
46- timerStop func() bool
47-}
48-
49-func (c *Core) pulseOn() bool {
50- return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
51-}
52-
53-// pulseFocus follows the window focus. held is false while something else
54-// (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
55-// stops, and the window getting it back is not a new focus. A new window
56-// stops the running pulse and waits pulseSettle before one starts. With the
57-// pulse off the focus is still followed, so turning it on pulses nothing.
58-func (c *Core) pulseFocus(window WindowID, held bool) {
59- p := &c.pulse
60- if !held || !c.pulseOn() {
61- p.id = 0
62- c.stopPulseTimer()
63- if held {
64- p.target = window
65- }
66- return
67- }
68- if window == p.target {
69- return
70- }
71- p.target, p.id = window, 0
72- c.stopPulseTimer()
73- if window == 0 {
74- return
75- }
76- p.since = c.now()
77- if c.justMapped(window) {
78- return
79- }
80- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseSettle)
81-}
82-
83-// justMapped reports whether the window got the focus as it mapped: opening
84-// an app is not a focus change, and its first frames may still be on their
85-// way. Its focus counts from the next change.
86-func (c *Core) justMapped(id WindowID) bool {
87- p := &c.pulse
88- return p.target == id && p.since.Sub(c.windows.lookup(id).mappedAt) < pulseSettle
89-}
90-
91-// pulseTick runs when the settle timer fired. It reports whether a pulse
92-// started.
93-func (c *Core) pulseTick() bool {
94- p := &c.pulse
95- p.timerC, p.timerStop = nil, nil
96- if p.target == 0 || !c.pulseOn() || !p.drawable || c.justMapped(p.target) {
97- return false
98- }
99- now := c.now()
100- if wait := pulseSettle - now.Sub(p.since); wait > 0 {
101- p.timerC, p.timerStop = newTimer(c.opts.Clock, wait)
102- return false
103- }
104- // A swipe or its landing slide still shows the neighbours: decide on
105- // the settled layout, or a window left alone would start a pulse cut
106- // short.
107- if !c.cur().settled() {
108- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseRecheck)
109- return false
110- }
111- if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
112- return false
113- }
114- p.id, p.start, p.last, p.lastAt = p.target, now, p.target, now
115- return true
116-}
117-
118-// advancePulse is the effect of the running pulse at now: a quick sine rise
119-// to focus.strength, then a smooth fall. It ends the pulse once over.
120-func (c *Core) advancePulse(now time.Time) float64 {
121- p := &c.pulse
122- if p.id == 0 {
123- return 0
124- }
125- peak := c.cfg.Focus.Strength
126- t := now.Sub(p.start)
127- switch {
128- case t < 0:
129- return 0
130- case t < pulseRise:
131- return peak * math.Sin(float64(t)/float64(pulseRise)*math.Pi/2)
132- case t < pulseRise+pulseFall:
133- x := 1 - float64(t-pulseRise)/float64(pulseFall)
134- return peak * x * x * (3 - 2*x)
135- }
136- p.id = 0
137- return 0
138-}
139-
140-// settled reports whether no swipe or slide moves the monitor's layout
141-// (screen.settled adds the rect motions).
142-func (m *Monitor) settled() bool {
143- if m.switchView.busy() {
144- return false
145- }
146- for w := range m.all() {
147- if w.view.busy() || w.stashView.busy() {
148- return false
149- }
150- }
151- return true
152-}
153-
154-// visibleCount is the number of windows of layout seen in v.
155-func visibleCount(layout []Placement, v viewport) int {
156- n := 0
157- for _, p := range layout {
158- if p.Preview == 0 && v.shows(p) {
159- n++
160- }
161- }
162- return n
163-}
164-
165-// pulsing reports whether a pulse runs on the screen.
166-func (c *Core) pulsing(sc *screen) bool {
167- return c.pulse.id != 0 && sc == c.cur()
168-}
169-
170-func (c *Core) stopPulseTimer() {
171- p := &c.pulse
172- if p.timerStop != nil {
173- p.timerStop()
174- }
175- p.timerC, p.timerStop = nil, nil
176-}
177-
178-// stopPulse ends the running pulse and its timer (security change). The
179-// focus is kept: the window focused again after an unlock does not pulse.
180-func (c *Core) stopPulse() {
181- c.stopPulseTimer()
182- c.pulse.id, c.pulse.drawable = 0, false
183-}
@@ -1,183 +0,0 @@
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, a window alone
18-// in the usable area (e.g. a maximized column), overview previews, a window focused as
19-// it maps and a protected session never pulse.
20-
21-const (
22- pulseSettle = 150 * time.Millisecond
23- pulseRise = 90 * time.Millisecond
24- pulseFall = 230 * time.Millisecond
25- pulseCooldown = time.Second
26- // pulseRecheck is how often a pulse waiting for a slide to land checks again.
27- pulseRecheck = pulseSettle / 3
28-)
29-
30-// focusPulse is the pulse state. target is the window focus last seen and
31-// since when; id is the window pulsing from start (0: none). last and lastAt
32-// are the previous pulse, for the cooldown. drawable is whether the last
33-// scene could show a pulse on target.
34-type focusPulse struct {
35- target WindowID
36- since time.Time
37- id WindowID
38- start time.Time
39- last WindowID
40- lastAt time.Time
41- drawable bool
42- // value is the effect of the last sample, kept for scenes published
43- // between the focused output's own flips.
44- value float64
45- timerC <-chan time.Time
46- timerStop func() bool
47-}
48-
49-func (c *Core) pulseOn() bool {
50- return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
51-}
52-
53-// pulseFocus follows the window focus. held is false while something else
54-// (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
55-// stops, and the window getting it back is not a new focus. A new window
56-// stops the running pulse and waits pulseSettle before one starts. With the
57-// pulse off the focus is still followed, so turning it on pulses nothing.
58-func (c *Core) pulseFocus(window WindowID, held bool) {
59- p := &c.pulse
60- if !held || !c.pulseOn() {
61- p.id = 0
62- c.stopPulseTimer()
63- if held {
64- p.target = window
65- }
66- return
67- }
68- if window == p.target {
69- return
70- }
71- p.target, p.id = window, 0
72- c.stopPulseTimer()
73- if window == 0 {
74- return
75- }
76- p.since = c.now()
77- if c.justMapped(window) {
78- return
79- }
80- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseSettle)
81-}
82-
83-// justMapped reports whether the window got the focus as it mapped: opening
84-// an app is not a focus change, and its first frames may still be on their
85-// way. Its focus counts from the next change.
86-func (c *Core) justMapped(id WindowID) bool {
87- p := &c.pulse
88- return p.target == id && p.since.Sub(c.windows.lookup(id).mappedAt) < pulseSettle
89-}
90-
91-// pulseTick runs when the settle timer fired. It reports whether a pulse
92-// started.
93-func (c *Core) pulseTick() bool {
94- p := &c.pulse
95- p.timerC, p.timerStop = nil, nil
96- if p.target == 0 || !c.pulseOn() || !p.drawable || c.justMapped(p.target) {
97- return false
98- }
99- now := c.now()
100- if wait := pulseSettle - now.Sub(p.since); wait > 0 {
101- p.timerC, p.timerStop = newTimer(c.opts.Clock, wait)
102- return false
103- }
104- // A swipe or its landing slide still shows the neighbours: decide on
105- // the settled layout, or a window left alone would start a pulse cut
106- // short.
107- if !c.cur().settled() {
108- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseRecheck)
109- return false
110- }
111- if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
112- return false
113- }
114- p.id, p.start, p.last, p.lastAt = p.target, now, p.target, now
115- return true
116-}
117-
118-// advancePulse is the effect of the running pulse at now: a quick sine rise
119-// to focus.strength, then a smooth fall. It ends the pulse once over.
120-func (c *Core) advancePulse(now time.Time) float64 {
121- p := &c.pulse
122- if p.id == 0 {
123- return 0
124- }
125- peak := c.cfg.Focus.Strength
126- t := now.Sub(p.start)
127- switch {
128- case t < 0:
129- return 0
130- case t < pulseRise:
131- return peak * math.Sin(float64(t)/float64(pulseRise)*math.Pi/2)
132- case t < pulseRise+pulseFall:
133- x := 1 - float64(t-pulseRise)/float64(pulseFall)
134- return peak * x * x * (3 - 2*x)
135- }
136- p.id = 0
137- return 0
138-}
139-
140-// settled reports whether no swipe or slide moves the monitor's layout
141-// (screen.settled adds the rect motions).
142-func (m *Monitor) settled() bool {
143- if m.switchView.busy() {
144- return false
145- }
146- for w := range m.all() {
147- if w.view.busy() || w.stashView.busy() {
148- return false
149- }
150- }
151- return true
152-}
153-
154-// visibleCount is the number of windows of layout seen in v.
155-func visibleCount(layout []Placement, v viewport) int {
156- n := 0
157- for _, p := range layout {
158- if p.Preview == 0 && v.shows(p) {
159- n++
160- }
161- }
162- return n
163-}
164-
165-// pulsing reports whether a pulse runs on the screen.
166-func (c *Core) pulsing(sc *screen) bool {
167- return c.pulse.id != 0 && sc == c.cur()
168-}
169-
170-func (c *Core) stopPulseTimer() {
171- p := &c.pulse
172- if p.timerStop != nil {
173- p.timerStop()
174- }
175- p.timerC, p.timerStop = nil, nil
176-}
177-
178-// stopPulse ends the running pulse and its timer (security change). The
179-// focus is kept: the window focused again after an unlock does not pulse.
180-func (c *Core) stopPulse() {
181- c.stopPulseTimer()
182- c.pulse.id, c.pulse.drawable = 0, false
183-}
D internal/core/pulse_test.go +0 -319
@@ -1,319 +0,0 @@
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.Animations.On = true
16- c.cfg.Focus.Animation = ports.FocusAnimationPulse
17- c.cfg.Focus.Strength = 0.05
18- c.cur().mon.AddWindow(1)
19- c.cur().mon.AddWindow(2)
20- indicatorScene(t, c)
21- ic.now = ic.now.Add(pulseSettle)
22- c.pulseTick()
23- ic.now = ic.now.Add(pulseCooldown)
24- indicatorScene(t, c)
25- return c, ic
26-}
27-
28-func pulseOf(s ports.Scene, id WindowID) float64 {
29- for _, w := range s.Windows {
30- if w.ID == id {
31- return w.FocusEffect
32- }
33- }
34- return -1
35-}
36-
37-func TestFocusPulseAfterSettle(t *testing.T) {
38- c, ic := pulseCore(t)
39- c.cur().mon.Current().FocusID(1)
40- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
41- t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
42- }
43- ic.now = ic.now.Add(pulseSettle)
44- if !c.pulseTick() {
45- t.Fatal("no pulse after settle")
46- }
47- if !c.animating() {
48- t.Fatal("a running pulse asks for no frames")
49- }
50- ic.now = ic.now.Add(pulseRise)
51- s := indicatorScene(t, c)
52- if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
53- t.Fatalf("peak %v, want the configured 0.05", p)
54- }
55- if pulseOf(s, 2) != 0 {
56- t.Fatal("unfocused window pulses")
57- }
58- ic.now = ic.now.Add(pulseFall)
59- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
60- t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
61- }
62-}
63-
64-// Switching faster than pulseSettle never pulses: only the last focus counts.
65-func TestFocusPulseDebounced(t *testing.T) {
66- c, ic := pulseCore(t)
67- for range 4 {
68- c.cur().mon.Current().FocusID(1)
69- indicatorScene(t, c)
70- ic.now = ic.now.Add(pulseSettle / 3)
71- c.cur().mon.Current().FocusID(2)
72- indicatorScene(t, c)
73- ic.now = ic.now.Add(pulseSettle / 3)
74- }
75- // A stale timer of an earlier focus fires: the focus is too recent.
76- if c.pulseTick() {
77- t.Fatal("pulse while switching")
78- }
79- if c.pulse.timerC == nil {
80- t.Fatal("no timer for the last focus")
81- }
82- ic.now = ic.now.Add(pulseSettle)
83- // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
84- if !c.pulseTick() || c.pulse.id != 2 {
85- t.Fatalf("last focus did not pulse: %d", c.pulse.id)
86- }
87-}
88-
89-// A focus change stops the running pulse; coming back within the cooldown
90-// does not pulse the same window again.
91-func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
92- c, ic := pulseCore(t)
93- c.cur().mon.Current().FocusID(1)
94- indicatorScene(t, c)
95- ic.now = ic.now.Add(pulseSettle)
96- c.pulseTick()
97- ic.now = ic.now.Add(pulseRise / 2)
98- c.cur().mon.Current().FocusID(2)
99- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
100- t.Fatal("pulse survived a focus change")
101- }
102- c.cur().mon.Current().FocusID(1)
103- indicatorScene(t, c)
104- ic.now = ic.now.Add(pulseSettle)
105- if c.pulseTick() {
106- t.Fatal("same window pulsed within the cooldown")
107- }
108-}
109-
110-// A window focused as it maps does not pulse; focusing it again later does.
111-func TestFocusPulseSkipsNewWindow(t *testing.T) {
112- c, ic := pulseCore(t)
113- c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
114- c.cur().mon.AddWindow(3)
115- indicatorScene(t, c)
116- if c.pulse.timerC != nil {
117- t.Fatal("opening a window arms the pulse")
118- }
119- ic.now = ic.now.Add(pulseSettle)
120- if c.pulseTick() || c.animating() {
121- t.Fatal("a new window pulses")
122- }
123- c.cur().mon.Current().FocusID(1)
124- indicatorScene(t, c)
125- c.cur().mon.Current().FocusID(3)
126- indicatorScene(t, c)
127- ic.now = ic.now.Add(pulseSettle)
128- if !c.pulseTick() || c.pulse.id != 3 {
129- t.Fatal("an existing window did not pulse")
130- }
131-}
132-
133-// A window that mapped earlier without the focus pulses when focused.
134-func TestFocusPulseWindowMappedEarlier(t *testing.T) {
135- c, ic := pulseCore(t)
136- c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
137- c.cur().mon.Current().FocusID(1)
138- indicatorScene(t, c)
139- ic.now = ic.now.Add(pulseSettle)
140- if !c.pulseTick() || c.pulse.id != 1 {
141- t.Fatal("a window mapped earlier did not pulse")
142- }
143-}
144-
145-func TestFocusPulseSkipsFullscreen(t *testing.T) {
146- c, ic := pulseCore(t)
147- c.cur().mon.Current().FocusID(1)
148- c.cur().mon.ToggleFullscreen()
149- indicatorScene(t, c)
150- ic.now = ic.now.Add(pulseSettle)
151- if c.pulseTick() || c.animating() {
152- t.Fatal("fullscreen window asks for pulse frames")
153- }
154- // Fullscreen entered mid-pulse ends it.
155- c.cur().mon.ToggleFullscreen()
156- c.cur().mon.Current().FocusID(2)
157- indicatorScene(t, c)
158- ic.now = ic.now.Add(pulseSettle)
159- if !c.pulseTick() {
160- t.Fatal("no pulse on the tiled window")
161- }
162- c.cur().mon.ToggleFullscreen()
163- if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
164- t.Fatal("pulse survived fullscreen")
165- }
166-}
167-
168-// A window alone on screen (a maximized column here) needs no pulse; once
169-// another window shows beside it, focusing it pulses again.
170-func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
171- c, ic := pulseCore(t)
172- c.cur().mon.Current().FocusID(1)
173- c.cur().mon.Current().ToggleFullWidth()
174- indicatorScene(t, c)
175- ic.now = ic.now.Add(pulseSettle)
176- if c.pulseTick() || c.animating() {
177- t.Fatal("a window alone on screen pulses")
178- }
179- c.cur().mon.Current().ToggleFullWidth()
180- c.cur().mon.Current().FocusID(2)
181- indicatorScene(t, c)
182- ic.now = ic.now.Add(pulseSettle)
183- if !c.pulseTick() || c.pulse.id != 2 {
184- t.Fatal("a window beside another did not pulse")
185- }
186-}
187-
188-// A cascade window alone in its band above a bottom panel is alone on
189-// screen: the next band, under the panel, does not count.
190-func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191- c, ic := pulseCore(t)
192- m := c.cur().mon
193- m.SetOverflow(OverflowCascade)
194- m.SetMaxColumns(1)
195- m.SetUsable(Rect{W: 300, H: 180})
196- m.Current().FocusID(1)
197- indicatorScene(t, c)
198- ic.now = ic.now.Add(pulseSettle)
199- if c.pulseTick() || c.animating() {
200- t.Fatal("a window alone in its band above a panel pulses")
201- }
202-}
203-
204-// A swipe, its landing slide or a transition still shows the neighbours: the
205-// pulse waits for the layout to settle, then decides on it.
206-func TestFocusPulseWaitsForSlide(t *testing.T) {
207- for _, tc := range []struct {
208- name string
209- slide func(*testing.T, *Core, time.Time)
210- alone bool
211- }{
212- {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
213- c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1)
214- }, true},
215- {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true},
216- {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false},
217- {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
218- sc := c.cur()
219- sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
220- sc.rectsWS = sc.mon.Current()
221- }, false},
222- {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
223- m := c.cur().mon
224- for id := WindowID(3); id <= 4; id++ {
225- m.AddWindow(id)
226- }
227- indicatorScene(t, c)
228- before := c.snapshot(now)
229- for range 3 {
230- c.applyAction(ActionFocusColumnLeft)
231- }
232- c.transition(before, now)
233- if !m.Current().view.motion.on || m.settled() {
234- t.Fatal("no camera transition after the focus moved the view")
235- }
236- }, false},
237- } {
238- t.Run(tc.name, func(t *testing.T) {
239- c, ic := pulseCore(t)
240- m := c.cur().mon
241- m.Current().FocusID(1)
242- indicatorScene(t, c)
243- tc.slide(t, c, ic.now)
244- indicatorScene(t, c)
245- ic.now = ic.now.Add(pulseSettle)
246- if c.pulseTick() || c.pulse.timerC == nil {
247- t.Fatal("pulse decided during a slide")
248- }
249- m.stopSwitch()
250- m.Current().view.stop()
251- c.cur().stopRects()
252- if tc.alone {
253- m.Current().ToggleFullWidth()
254- }
255- indicatorScene(t, c)
256- ic.now = ic.now.Add(pulseRecheck)
257- if got := c.pulseTick(); got == tc.alone {
258- t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
259- }
260- })
261- }
262-}
263-
264-// A popup grab or layer taking the keyboard stops the pulse; the window
265-// getting it back is not a new focus and does not pulse again.
266-func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
267- c, ic := pulseCore(t)
268- c.cur().mon.Current().FocusID(1)
269- indicatorScene(t, c)
270- ic.now = ic.now.Add(pulseSettle)
271- c.pulseTick()
272- // The keyboard goes elsewhere (held false), then comes back.
273- c.pulseFocus(1, false)
274- if c.animating() {
275- t.Fatal("pulse kept while the keyboard is elsewhere")
276- }
277- ic.now = ic.now.Add(2 * pulseCooldown)
278- c.pulseFocus(1, true)
279- if c.pulse.timerC != nil {
280- t.Fatal("returning keyboard counts as a new focus")
281- }
282-}
283-
284-func TestFocusPulseOff(t *testing.T) {
285- c, ic := pulseCore(t)
286- c.cfg.Focus.Animation = ports.FocusAnimationOff
287- c.cur().mon.Current().FocusID(1)
288- indicatorScene(t, c)
289- if c.pulse.timerC != nil {
290- t.Fatal("timer armed with the pulse off")
291- }
292- ic.now = ic.now.Add(pulseSettle)
293- if c.pulseTick() || c.animating() {
294- t.Fatal("pulse with focus.animation off")
295- }
296- // Turning it back on does not pulse the window already focused.
297- c.cfg.Focus.Animation = ports.FocusAnimationPulse
298- indicatorScene(t, c)
299- if c.pulse.timerC != nil {
300- t.Fatal("enabling the pulse pulses the focused window")
301- }
302-}
303-
304-func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
305- c, ic := pulseCore(t)
306- c.cfg.Animations.On = false
307- if c.pulseOn() {
308- t.Fatal("pulse on with animations off")
309- }
310- c.cur().mon.Current().FocusID(1)
311- indicatorScene(t, c)
312- if c.pulse.timerC != nil || c.pulse.id != 0 {
313- t.Fatal("pulse armed with animations off")
314- }
315- ic.now = ic.now.Add(pulseSettle)
316- if c.pulseTick() || c.animating() {
317- t.Fatal("pulse with animations off")
318- }
319-}
@@ -1,319 +0,0 @@
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.Animations.On = true
16- c.cfg.Focus.Animation = ports.FocusAnimationPulse
17- c.cfg.Focus.Strength = 0.05
18- c.cur().mon.AddWindow(1)
19- c.cur().mon.AddWindow(2)
20- indicatorScene(t, c)
21- ic.now = ic.now.Add(pulseSettle)
22- c.pulseTick()
23- ic.now = ic.now.Add(pulseCooldown)
24- indicatorScene(t, c)
25- return c, ic
26-}
27-
28-func pulseOf(s ports.Scene, id WindowID) float64 {
29- for _, w := range s.Windows {
30- if w.ID == id {
31- return w.FocusEffect
32- }
33- }
34- return -1
35-}
36-
37-func TestFocusPulseAfterSettle(t *testing.T) {
38- c, ic := pulseCore(t)
39- c.cur().mon.Current().FocusID(1)
40- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
41- t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
42- }
43- ic.now = ic.now.Add(pulseSettle)
44- if !c.pulseTick() {
45- t.Fatal("no pulse after settle")
46- }
47- if !c.animating() {
48- t.Fatal("a running pulse asks for no frames")
49- }
50- ic.now = ic.now.Add(pulseRise)
51- s := indicatorScene(t, c)
52- if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
53- t.Fatalf("peak %v, want the configured 0.05", p)
54- }
55- if pulseOf(s, 2) != 0 {
56- t.Fatal("unfocused window pulses")
57- }
58- ic.now = ic.now.Add(pulseFall)
59- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
60- t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
61- }
62-}
63-
64-// Switching faster than pulseSettle never pulses: only the last focus counts.
65-func TestFocusPulseDebounced(t *testing.T) {
66- c, ic := pulseCore(t)
67- for range 4 {
68- c.cur().mon.Current().FocusID(1)
69- indicatorScene(t, c)
70- ic.now = ic.now.Add(pulseSettle / 3)
71- c.cur().mon.Current().FocusID(2)
72- indicatorScene(t, c)
73- ic.now = ic.now.Add(pulseSettle / 3)
74- }
75- // A stale timer of an earlier focus fires: the focus is too recent.
76- if c.pulseTick() {
77- t.Fatal("pulse while switching")
78- }
79- if c.pulse.timerC == nil {
80- t.Fatal("no timer for the last focus")
81- }
82- ic.now = ic.now.Add(pulseSettle)
83- // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
84- if !c.pulseTick() || c.pulse.id != 2 {
85- t.Fatalf("last focus did not pulse: %d", c.pulse.id)
86- }
87-}
88-
89-// A focus change stops the running pulse; coming back within the cooldown
90-// does not pulse the same window again.
91-func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
92- c, ic := pulseCore(t)
93- c.cur().mon.Current().FocusID(1)
94- indicatorScene(t, c)
95- ic.now = ic.now.Add(pulseSettle)
96- c.pulseTick()
97- ic.now = ic.now.Add(pulseRise / 2)
98- c.cur().mon.Current().FocusID(2)
99- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
100- t.Fatal("pulse survived a focus change")
101- }
102- c.cur().mon.Current().FocusID(1)
103- indicatorScene(t, c)
104- ic.now = ic.now.Add(pulseSettle)
105- if c.pulseTick() {
106- t.Fatal("same window pulsed within the cooldown")
107- }
108-}
109-
110-// A window focused as it maps does not pulse; focusing it again later does.
111-func TestFocusPulseSkipsNewWindow(t *testing.T) {
112- c, ic := pulseCore(t)
113- c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
114- c.cur().mon.AddWindow(3)
115- indicatorScene(t, c)
116- if c.pulse.timerC != nil {
117- t.Fatal("opening a window arms the pulse")
118- }
119- ic.now = ic.now.Add(pulseSettle)
120- if c.pulseTick() || c.animating() {
121- t.Fatal("a new window pulses")
122- }
123- c.cur().mon.Current().FocusID(1)
124- indicatorScene(t, c)
125- c.cur().mon.Current().FocusID(3)
126- indicatorScene(t, c)
127- ic.now = ic.now.Add(pulseSettle)
128- if !c.pulseTick() || c.pulse.id != 3 {
129- t.Fatal("an existing window did not pulse")
130- }
131-}
132-
133-// A window that mapped earlier without the focus pulses when focused.
134-func TestFocusPulseWindowMappedEarlier(t *testing.T) {
135- c, ic := pulseCore(t)
136- c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
137- c.cur().mon.Current().FocusID(1)
138- indicatorScene(t, c)
139- ic.now = ic.now.Add(pulseSettle)
140- if !c.pulseTick() || c.pulse.id != 1 {
141- t.Fatal("a window mapped earlier did not pulse")
142- }
143-}
144-
145-func TestFocusPulseSkipsFullscreen(t *testing.T) {
146- c, ic := pulseCore(t)
147- c.cur().mon.Current().FocusID(1)
148- c.cur().mon.ToggleFullscreen()
149- indicatorScene(t, c)
150- ic.now = ic.now.Add(pulseSettle)
151- if c.pulseTick() || c.animating() {
152- t.Fatal("fullscreen window asks for pulse frames")
153- }
154- // Fullscreen entered mid-pulse ends it.
155- c.cur().mon.ToggleFullscreen()
156- c.cur().mon.Current().FocusID(2)
157- indicatorScene(t, c)
158- ic.now = ic.now.Add(pulseSettle)
159- if !c.pulseTick() {
160- t.Fatal("no pulse on the tiled window")
161- }
162- c.cur().mon.ToggleFullscreen()
163- if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
164- t.Fatal("pulse survived fullscreen")
165- }
166-}
167-
168-// A window alone on screen (a maximized column here) needs no pulse; once
169-// another window shows beside it, focusing it pulses again.
170-func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
171- c, ic := pulseCore(t)
172- c.cur().mon.Current().FocusID(1)
173- c.cur().mon.Current().ToggleFullWidth()
174- indicatorScene(t, c)
175- ic.now = ic.now.Add(pulseSettle)
176- if c.pulseTick() || c.animating() {
177- t.Fatal("a window alone on screen pulses")
178- }
179- c.cur().mon.Current().ToggleFullWidth()
180- c.cur().mon.Current().FocusID(2)
181- indicatorScene(t, c)
182- ic.now = ic.now.Add(pulseSettle)
183- if !c.pulseTick() || c.pulse.id != 2 {
184- t.Fatal("a window beside another did not pulse")
185- }
186-}
187-
188-// A cascade window alone in its band above a bottom panel is alone on
189-// screen: the next band, under the panel, does not count.
190-func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191- c, ic := pulseCore(t)
192- m := c.cur().mon
193- m.SetOverflow(OverflowCascade)
194- m.SetMaxColumns(1)
195- m.SetUsable(Rect{W: 300, H: 180})
196- m.Current().FocusID(1)
197- indicatorScene(t, c)
198- ic.now = ic.now.Add(pulseSettle)
199- if c.pulseTick() || c.animating() {
200- t.Fatal("a window alone in its band above a panel pulses")
201- }
202-}
203-
204-// A swipe, its landing slide or a transition still shows the neighbours: the
205-// pulse waits for the layout to settle, then decides on it.
206-func TestFocusPulseWaitsForSlide(t *testing.T) {
207- for _, tc := range []struct {
208- name string
209- slide func(*testing.T, *Core, time.Time)
210- alone bool
211- }{
212- {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
213- c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1)
214- }, true},
215- {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true},
216- {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false},
217- {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
218- sc := c.cur()
219- sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
220- sc.rectsWS = sc.mon.Current()
221- }, false},
222- {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
223- m := c.cur().mon
224- for id := WindowID(3); id <= 4; id++ {
225- m.AddWindow(id)
226- }
227- indicatorScene(t, c)
228- before := c.snapshot(now)
229- for range 3 {
230- c.applyAction(ActionFocusColumnLeft)
231- }
232- c.transition(before, now)
233- if !m.Current().view.motion.on || m.settled() {
234- t.Fatal("no camera transition after the focus moved the view")
235- }
236- }, false},
237- } {
238- t.Run(tc.name, func(t *testing.T) {
239- c, ic := pulseCore(t)
240- m := c.cur().mon
241- m.Current().FocusID(1)
242- indicatorScene(t, c)
243- tc.slide(t, c, ic.now)
244- indicatorScene(t, c)
245- ic.now = ic.now.Add(pulseSettle)
246- if c.pulseTick() || c.pulse.timerC == nil {
247- t.Fatal("pulse decided during a slide")
248- }
249- m.stopSwitch()
250- m.Current().view.stop()
251- c.cur().stopRects()
252- if tc.alone {
253- m.Current().ToggleFullWidth()
254- }
255- indicatorScene(t, c)
256- ic.now = ic.now.Add(pulseRecheck)
257- if got := c.pulseTick(); got == tc.alone {
258- t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
259- }
260- })
261- }
262-}
263-
264-// A popup grab or layer taking the keyboard stops the pulse; the window
265-// getting it back is not a new focus and does not pulse again.
266-func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
267- c, ic := pulseCore(t)
268- c.cur().mon.Current().FocusID(1)
269- indicatorScene(t, c)
270- ic.now = ic.now.Add(pulseSettle)
271- c.pulseTick()
272- // The keyboard goes elsewhere (held false), then comes back.
273- c.pulseFocus(1, false)
274- if c.animating() {
275- t.Fatal("pulse kept while the keyboard is elsewhere")
276- }
277- ic.now = ic.now.Add(2 * pulseCooldown)
278- c.pulseFocus(1, true)
279- if c.pulse.timerC != nil {
280- t.Fatal("returning keyboard counts as a new focus")
281- }
282-}
283-
284-func TestFocusPulseOff(t *testing.T) {
285- c, ic := pulseCore(t)
286- c.cfg.Focus.Animation = ports.FocusAnimationOff
287- c.cur().mon.Current().FocusID(1)
288- indicatorScene(t, c)
289- if c.pulse.timerC != nil {
290- t.Fatal("timer armed with the pulse off")
291- }
292- ic.now = ic.now.Add(pulseSettle)
293- if c.pulseTick() || c.animating() {
294- t.Fatal("pulse with focus.animation off")
295- }
296- // Turning it back on does not pulse the window already focused.
297- c.cfg.Focus.Animation = ports.FocusAnimationPulse
298- indicatorScene(t, c)
299- if c.pulse.timerC != nil {
300- t.Fatal("enabling the pulse pulses the focused window")
301- }
302-}
303-
304-func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
305- c, ic := pulseCore(t)
306- c.cfg.Animations.On = false
307- if c.pulseOn() {
308- t.Fatal("pulse on with animations off")
309- }
310- c.cur().mon.Current().FocusID(1)
311- indicatorScene(t, c)
312- if c.pulse.timerC != nil || c.pulse.id != 0 {
313- t.Fatal("pulse armed with animations off")
314- }
315- ic.now = ic.now.Add(pulseSettle)
316- if c.pulseTick() || c.animating() {
317- t.Fatal("pulse with animations off")
318- }
319-}
M internal/core/scene.go +3 -12
@@ -8,8 +8,7 @@ import (
88 )
99
1010 // sceneFor builds the scene of screen i and sends the configures it sizes.
11-// drawable reports that it can show the focus pulse on the focused window.
12-func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
11+func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView) (scene ports.Scene, err error) {
1312 o := sc.mon.Output()
1413 // view.frame is the viewport of the workspace on screen: the whole
1514 // output unless it has a size override (never in the overview).
@@ -68,8 +67,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
6867 // Only the focused output lights the focused window's lines.
6968 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
7069 }
71- // A window alone on screen needs no pulse to show it has the focus.
72- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, view) == 1
7370 for k, p := range layout {
7471 ps := settled[k]
7572 // Only the focused output has an activated window.
@@ -97,12 +94,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
9794 }
9895 continue
9996 }
100- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) {
101- drawable = true
102- if p.ID == c.pulse.id {
103- sw.FocusEffect = pulse
104- }
105- }
10697 scene.Windows = append(scene.Windows, sw)
10798 t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}
10899 if !p.Hidden && p.Preview == 0 {
@@ -120,7 +111,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
120111 cp, t := c.captureConfigure(sc, ps, t)
121112 if v, send := c.configures.nextWithCapture(ps, t, cp); send {
122113 if err := c.command(ctx, v); err != nil {
123- return ports.Scene{}, false, err
114+ return ports.Scene{}, err
124115 }
125116 c.configures.mark(v)
126117 }
@@ -160,5 +151,5 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
160151 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
161152 }
162153 }
163- return scene, drawable, nil
154+ return scene, nil
164155 }
@@ -8,8 +8,7 @@ import (
8 )8 )
9 9
10 // sceneFor builds the scene of screen i and sends the configures it sizes.10 // sceneFor builds the scene of screen i and sends the configures it sizes.
11-// drawable reports that it can show the focus pulse on the focused window.11+func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView) (scene ports.Scene, err error) {
12-func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
13 o := sc.mon.Output()12 o := sc.mon.Output()
14 // view.frame is the viewport of the workspace on screen: the whole13 // view.frame is the viewport of the workspace on screen: the whole
15 // output unless it has a size override (never in the overview).14 // output unless it has a size override (never in the overview).
@@ -68,8 +67,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
68 // Only the focused output lights the focused window's lines.67 // Only the focused output lights the focused window's lines.
69 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)68 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
70 }69 }
71- // A window alone on screen needs no pulse to show it has the focus.
72- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, view) == 1
73 for k, p := range layout {70 for k, p := range layout {
74 ps := settled[k]71 ps := settled[k]
75 // Only the focused output has an activated window.72 // Only the focused output has an activated window.
@@ -97,12 +94,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
97 }94 }
98 continue95 continue
99 }96 }
100- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) {
101- drawable = true
102- if p.ID == c.pulse.id {
103- sw.FocusEffect = pulse
104- }
105- }
106 scene.Windows = append(scene.Windows, sw)97 scene.Windows = append(scene.Windows, sw)
107 t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}98 t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}
108 if !p.Hidden && p.Preview == 0 {99 if !p.Hidden && p.Preview == 0 {
@@ -120,7 +111,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
120 cp, t := c.captureConfigure(sc, ps, t)111 cp, t := c.captureConfigure(sc, ps, t)
121 if v, send := c.configures.nextWithCapture(ps, t, cp); send {112 if v, send := c.configures.nextWithCapture(ps, t, cp); send {
122 if err := c.command(ctx, v); err != nil {113 if err := c.command(ctx, v); err != nil {
123- return ports.Scene{}, false, err114+ return ports.Scene{}, err
124 }115 }
125 c.configures.mark(v)116 c.configures.mark(v)
126 }117 }
@@ -160,5 +151,5 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
160 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)151 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
161 }152 }
162 }153 }
163- return scene, drawable, nil154+ return scene, nil
164 }155 }
M internal/core/scene_animating_test.go +9 -25
@@ -55,33 +55,17 @@ func TestSceneAnimatingFollowsSprings(t *testing.T) {
5555 }
5656 }
5757
58-// A pulse cancelled because nothing can show it (fullscreen) publishes the
59-// focused scene settled, with a fresh Seq: otherwise outputs keep the
60-// animating scene and wake core on every flip.
61-func TestCancelledPulseClearsAnimating(t *testing.T) {
62- c, ic := pulseCore(t)
63- c.cur().mon.Current().FocusID(1)
64- indicatorScene(t, c)
65- ic.now = ic.now.Add(pulseSettle)
66- if !c.pulseTick() {
67- t.Fatal("bad setup: no pulse")
68- }
69- running := indicatorScene(t, c)
70- if !running.Animating {
71- t.Fatal("bad setup: the pulsing scene is not animating")
72- }
73- c.cur().mon.ToggleFullscreen()
74- cancelled := indicatorScene(t, c)
75- if cancelled.Animating || c.animating() {
76- t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating())
77- }
78- if cancelled.Seq == running.Seq {
79- t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq)
58+// stepScenes steps only the screen and returns the scene of each output.
59+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
60+ t.Helper()
61+ if err := c.step(context.Background(), only); err != nil {
62+ t.Fatal(err)
8063 }
81- idle := indicatorScene(t, c)
82- if idle.Animating || idle.Seq != cancelled.Seq {
83- t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq)
64+ out := map[string]ports.Scene{}
65+ for _, s := range <-c.ch.Scenes {
66+ out[s.Output] = s
8467 }
68+ return out
8569 }
8670
8771 // Animating is per output: a spring on one does not mark the other.
@@ -55,33 +55,17 @@ func TestSceneAnimatingFollowsSprings(t *testing.T) {
55 }55 }
56 }56 }
57 57
58-// A pulse cancelled because nothing can show it (fullscreen) publishes the58+// stepScenes steps only the screen and returns the scene of each output.
59-// focused scene settled, with a fresh Seq: otherwise outputs keep the59+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
60-// animating scene and wake core on every flip.60+ t.Helper()
61-func TestCancelledPulseClearsAnimating(t *testing.T) {61+ if err := c.step(context.Background(), only); err != nil {
62- c, ic := pulseCore(t)62+ t.Fatal(err)
63- c.cur().mon.Current().FocusID(1)
64- indicatorScene(t, c)
65- ic.now = ic.now.Add(pulseSettle)
66- if !c.pulseTick() {
67- t.Fatal("bad setup: no pulse")
68- }
69- running := indicatorScene(t, c)
70- if !running.Animating {
71- t.Fatal("bad setup: the pulsing scene is not animating")
72- }
73- c.cur().mon.ToggleFullscreen()
74- cancelled := indicatorScene(t, c)
75- if cancelled.Animating || c.animating() {
76- t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating())
77- }
78- if cancelled.Seq == running.Seq {
79- t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq)
80 }63 }
81- idle := indicatorScene(t, c)64+ out := map[string]ports.Scene{}
82- if idle.Animating || idle.Seq != cancelled.Seq {65+ for _, s := range <-c.ch.Scenes {
83- t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq)66+ out[s.Output] = s
84 }67 }
68+ return out
85 }69 }
86 70
87 // Animating is per output: a spring on one does not mark the other.71 // Animating is per output: a spring on one does not mark the other.
M internal/core/screens.go +0 -4
@@ -56,10 +56,6 @@ type screen struct {
5656 // springing reports whether a camera or rect spring runs on the screen.
5757 func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
5858
59-// settled reports whether nothing moves the screen's layout: no swipe,
60-// no slide, no rect motion.
61-func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
62-
6359 // stopRects drops the rect motions: the windows are drawn where they are.
6460 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
6561
@@ -56,10 +56,6 @@ type screen struct {
56 // springing reports whether a camera or rect spring runs on the screen.56 // springing reports whether a camera or rect spring runs on the screen.
57 func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }57 func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
58 58
59-// settled reports whether nothing moves the screen's layout: no swipe,
60-// no slide, no rect motion.
61-func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
62-
63 // stopRects drops the rect motions: the windows are drawn where they are.59 // stopRects drops the rect motions: the windows are drawn where they are.
64 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }60 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
65 61
M internal/core/security.go +0 -1
@@ -47,7 +47,6 @@ func (c *Core) syncSecurity() bool {
4747 // protected epoch clears that focus.
4848 c.gesture = clientGesture{}
4949 c.stopAnimations()
50- c.stopPulse()
5150 c.cancelSwitcher()
5251 c.stopRepeat()
5352 if state.Protected {
@@ -47,7 +47,6 @@ func (c *Core) syncSecurity() bool {
47 // protected epoch clears that focus.47 // protected epoch clears that focus.
48 c.gesture = clientGesture{}48 c.gesture = clientGesture{}
49 c.stopAnimations()49 c.stopAnimations()
50- c.stopPulse()
51 c.cancelSwitcher()50 c.cancelSwitcher()
52 c.stopRepeat()51 c.stopRepeat()
53 if state.Protected {52 if state.Protected {
M internal/core/transition.go +1 -2
@@ -701,8 +701,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
701701 // while it shows on another) is animated on both screens: the destination's
702702 // windows slide in from where the source drew them (no workspace slide
703703 // there), the source slides to its next workspace. The focus-monitor-*
704-// actions move nothing: no window changes place, so they are not animated
705-// (the focus pulse marks the new focus).
704+// actions move nothing: no window changes place, so they are not animated.
706705 func (c *Core) transition(before []viewShot, now time.Time) {
707706 if !c.animOn() {
708707 return
@@ -701,8 +701,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
701 // while it shows on another) is animated on both screens: the destination's701 // while it shows on another) is animated on both screens: the destination's
702 // windows slide in from where the source drew them (no workspace slide702 // windows slide in from where the source drew them (no workspace slide
703 // there), the source slides to its next workspace. The focus-monitor-*703 // there), the source slides to its next workspace. The focus-monitor-*
704-// actions move nothing: no window changes place, so they are not animated704+// actions move nothing: no window changes place, so they are not animated.
705-// (the focus pulse marks the new focus).
706 func (c *Core) transition(before []viewShot, now time.Time) {705 func (c *Core) transition(before []viewShot, now time.Time) {
707 if !c.animOn() {706 if !c.animOn() {
708 return707 return
M internal/core/viewport_test.go +4 -6
@@ -184,20 +184,18 @@ func TestTileStopsAtPanel(t *testing.T) {
184184 }
185185 }
186186
187-// Panels that leave no room draw no tile, a leaving one included, and the
188-// focus pulse has nothing to show.
187+// Panels that leave no room draw no tile, a leaving one included.
189188 func TestNoUsableAreaDrawsNoTile(t *testing.T) {
190189 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
191190 c.cur().mon.AddWindow(1)
192- c.pulse.target = 1
193191 scene := check(map[WindowID]bool{1: false})
194- if scene.Draws(scene.Windows[0]) || c.pulse.drawable {
195- t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)
192+ if scene.Draws(scene.Windows[0]) {
193+ t.Fatalf("tile drawn with no usable area")
196194 }
197195 sc := c.cur()
198196 sc.shown = slices.Clone(sc.shownLayout())
199197 sc.shown[0].Leaving = true
200- scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)
198+ scene, err := c.sceneFor(context.Background(), 0, sc, nil, nil)
201199 if err != nil {
202200 t.Fatal(err)
203201 }
@@ -184,20 +184,18 @@ func TestTileStopsAtPanel(t *testing.T) {
184 }184 }
185 }185 }
186 186
187-// Panels that leave no room draw no tile, a leaving one included, and the187+// Panels that leave no room draw no tile, a leaving one included.
188-// focus pulse has nothing to show.
189 func TestNoUsableAreaDrawsNoTile(t *testing.T) {188 func TestNoUsableAreaDrawsNoTile(t *testing.T) {
190 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})189 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
191 c.cur().mon.AddWindow(1)190 c.cur().mon.AddWindow(1)
192- c.pulse.target = 1
193 scene := check(map[WindowID]bool{1: false})191 scene := check(map[WindowID]bool{1: false})
194- if scene.Draws(scene.Windows[0]) || c.pulse.drawable {192+ if scene.Draws(scene.Windows[0]) {
195- t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)193+ t.Fatalf("tile drawn with no usable area")
196 }194 }
197 sc := c.cur()195 sc := c.cur()
198 sc.shown = slices.Clone(sc.shownLayout())196 sc.shown = slices.Clone(sc.shownLayout())
199 sc.shown[0].Leaving = true197 sc.shown[0].Leaving = true
200- scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)198+ scene, err := c.sceneFor(context.Background(), 0, sc, nil, nil)
201 if err != nil {199 if err != nil {
202 t.Fatal(err)200 t.Fatal(err)
203 }201 }
M internal/ports/config.go +11 -14
@@ -89,14 +89,6 @@ type OutputLayout struct {
8989 LayoutRules
9090 }
9191
92-// Focus indicator: the animation (Config.Focus.Animation) shapes in time
93-// the visual effect (Config.Focus.Effect) drawn on a newly focused window.
94-const (
95- FocusAnimationOff = "off"
96- FocusAnimationPulse = "pulse"
97- FocusEffectScreen = "screen"
98-)
99-
10092 // Config is the parsed compositor configuration (see the config adapter for keys).
10193 type Config struct {
10294 Keyboard struct {
@@ -148,7 +140,7 @@ type Config struct {
148140 // motion; the next motion shows it. 0 never hides it.
149141 Cursor struct{ HideAfter time.Duration }
150142 // Animations: On enables motion (off lands everything instantly);
151- // Slowdown scales every spring's duration (not the focus pulse), 0.1 to
143+ // Slowdown scales every spring's duration, 0.1 to
152144 // 10 (the config's animations.speed maps to it).
153145 Animations struct {
154146 On bool
@@ -158,11 +150,16 @@ type Config struct {
158150 // FollowMove shows the target workspace after a column or window
159151 // moves to it.
160152 FollowMove bool
161- // Animation and Effect mark a window that just got the focus:
162- // FocusAnimation* (off disables it) and FocusEffect*. Strength is
163- // the effect's peak, 0.01 to 0.2.
164- Animation, Effect string
165- Strength float64
153+ // Flash briefly draws an inner border on the entity an action changed
154+ // (window, column, output). FlashWidth is in logical pixels (1 to 8),
155+ // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
156+ // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
157+ // empty for Border.Active.
158+ Flash bool
159+ FlashWidth int
160+ FlashOpacity float64
161+ FlashDuration time.Duration
162+ FlashColor string
166163 }
167164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
168165 Workspaces []WorkspaceConfig
@@ -89,14 +89,6 @@ type OutputLayout struct {
89 LayoutRules89 LayoutRules
90 }90 }
91 91
92-// Focus indicator: the animation (Config.Focus.Animation) shapes in time
93-// the visual effect (Config.Focus.Effect) drawn on a newly focused window.
94-const (
95- FocusAnimationOff = "off"
96- FocusAnimationPulse = "pulse"
97- FocusEffectScreen = "screen"
98-)
99-
100 // Config is the parsed compositor configuration (see the config adapter for keys).92 // Config is the parsed compositor configuration (see the config adapter for keys).
101 type Config struct {93 type Config struct {
102 Keyboard struct {94 Keyboard struct {
@@ -148,7 +140,7 @@ type Config struct {
148 // motion; the next motion shows it. 0 never hides it.140 // motion; the next motion shows it. 0 never hides it.
149 Cursor struct{ HideAfter time.Duration }141 Cursor struct{ HideAfter time.Duration }
150 // Animations: On enables motion (off lands everything instantly);142 // Animations: On enables motion (off lands everything instantly);
151- // Slowdown scales every spring's duration (not the focus pulse), 0.1 to143+ // Slowdown scales every spring's duration, 0.1 to
152 // 10 (the config's animations.speed maps to it).144 // 10 (the config's animations.speed maps to it).
153 Animations struct {145 Animations struct {
154 On bool146 On bool
@@ -158,11 +150,16 @@ type Config struct {
158 // FollowMove shows the target workspace after a column or window150 // FollowMove shows the target workspace after a column or window
159 // moves to it.151 // moves to it.
160 FollowMove bool152 FollowMove bool
161- // Animation and Effect mark a window that just got the focus:153+ // Flash briefly draws an inner border on the entity an action changed
162- // FocusAnimation* (off disables it) and FocusEffect*. Strength is154+ // (window, column, output). FlashWidth is in logical pixels (1 to 8),
163- // the effect's peak, 0.01 to 0.2.155+ // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
164- Animation, Effect string156+ // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
165- Strength float64157+ // empty for Border.Active.
158+ Flash bool
159+ FlashWidth int
160+ FlashOpacity float64
161+ FlashDuration time.Duration
162+ FlashColor string
166 }163 }
167 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
168 Workspaces []WorkspaceConfig165 Workspaces []WorkspaceConfig
M internal/ports/scene.go +15 -11
@@ -69,7 +69,10 @@ type Scene struct {
6969 // drawn over everything; nil when none. Captures never hold them: the
7070 // capture pipeline serves requests from a scene without them.
7171 CaptureIndicators []CaptureIndicator
72- // Animating is set by core when a spring or the focus pulse runs on
72+ // Flash is the focus flash drawn over everything of this output; nil
73+ // draws nothing.
74+ Flash *FocusFlash
75+ // Animating is set by core when a spring or a focus flash runs on
7376 // this output after this frame was computed: the output reports the
7477 // flip of the scene to core (OutputFrame), which moves the animation to
7578 // the next frame. A scene of a settled output carries false, so a flip
@@ -176,6 +179,17 @@ type SceneLayer struct {
176179 Rect Rect
177180 }
178181
182+// FocusFlash is a brief inner border marking the entity an action just
183+// changed: a window, a column or a whole output. Rect is in logical
184+// pixels, Width the border width inside it, Color #rrggbb and Alpha its
185+// opacity at this frame (0 to 1).
186+type FocusFlash struct {
187+ Rect Rect
188+ Width int
189+ Color string
190+ Alpha float64
191+}
192+
179193 // SceneWindow carries core → renderer window placement.
180194 type SceneWindow struct {
181195 ID WindowID
@@ -195,16 +209,6 @@ type SceneWindow struct {
195209 // or leaving animates it; a faded window is composed, never scanned
196210 // out.
197211 Fade float64
198- // FocusEffect, above 0, is the focus indicator's effect at this frame
199- // (focus.animation, focus.effect): the window's surfaces are lifted
200- // toward white by that fraction (screen blend). Renderers without the
201- // effect ignore it.
202- //
203- // Only focus.effect = screen exists, so the effect kind is not carried:
204- // core ignores Config.Focus.Effect and the shaders always apply screen
205- // (compose.frag, compose_hdr.frag, push constant mapy.z). A second effect
206- // needs its kind here and in the push constants (a misc flag).
207- FocusEffect float64
208212 // Preview, above 0, draws the window's surfaces that much smaller in
209213 // Rect (an overview thumbnail): the client keeps its size. A preview
210214 // is a card: it never opens the floats.
@@ -69,7 +69,10 @@ type Scene struct {
69 // drawn over everything; nil when none. Captures never hold them: the69 // drawn over everything; nil when none. Captures never hold them: the
70 // capture pipeline serves requests from a scene without them.70 // capture pipeline serves requests from a scene without them.
71 CaptureIndicators []CaptureIndicator71 CaptureIndicators []CaptureIndicator
72- // Animating is set by core when a spring or the focus pulse runs on72+ // Flash is the focus flash drawn over everything of this output; nil
73+ // draws nothing.
74+ Flash *FocusFlash
75+ // Animating is set by core when a spring or a focus flash runs on
73 // this output after this frame was computed: the output reports the76 // this output after this frame was computed: the output reports the
74 // flip of the scene to core (OutputFrame), which moves the animation to77 // flip of the scene to core (OutputFrame), which moves the animation to
75 // the next frame. A scene of a settled output carries false, so a flip78 // the next frame. A scene of a settled output carries false, so a flip
@@ -176,6 +179,17 @@ type SceneLayer struct {
176 Rect Rect179 Rect Rect
177 }180 }
178 181
182+// FocusFlash is a brief inner border marking the entity an action just
183+// changed: a window, a column or a whole output. Rect is in logical
184+// pixels, Width the border width inside it, Color #rrggbb and Alpha its
185+// opacity at this frame (0 to 1).
186+type FocusFlash struct {
187+ Rect Rect
188+ Width int
189+ Color string
190+ Alpha float64
191+}
192+
179 // SceneWindow carries core → renderer window placement.193 // SceneWindow carries core → renderer window placement.
180 type SceneWindow struct {194 type SceneWindow struct {
181 ID WindowID195 ID WindowID
@@ -195,16 +209,6 @@ type SceneWindow struct {
195 // or leaving animates it; a faded window is composed, never scanned209 // or leaving animates it; a faded window is composed, never scanned
196 // out.210 // out.
197 Fade float64211 Fade float64
198- // FocusEffect, above 0, is the focus indicator's effect at this frame
199- // (focus.animation, focus.effect): the window's surfaces are lifted
200- // toward white by that fraction (screen blend). Renderers without the
201- // effect ignore it.
202- //
203- // Only focus.effect = screen exists, so the effect kind is not carried:
204- // core ignores Config.Focus.Effect and the shaders always apply screen
205- // (compose.frag, compose_hdr.frag, push constant mapy.z). A second effect
206- // needs its kind here and in the push constants (a misc flag).
207- FocusEffect float64
208 // Preview, above 0, draws the window's surfaces that much smaller in212 // Preview, above 0, draws the window's surfaces that much smaller in
209 // Rect (an overview thumbnail): the client keeps its size. A preview213 // Rect (an overview thumbnail): the client keeps its size. A preview
210 // is a card: it never opens the floats.214 // is a card: it never opens the floats.
M internal/ports/scene_same_test.go +2 -2
@@ -83,9 +83,9 @@ func TestSceneSameAs(t *testing.T) {
8383 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
8484 // update Scene.SameAs, then this count.
8585 func TestSceneSameAsCoversEveryField(t *testing.T) {
86- // 22 with Animating, which SameAs intentionally does not compare: it
86+ // 23 with Animating, which SameAs intentionally does not compare: it
8787 // only schedules the next frame, it draws nothing.
88- const fields = 22
88+ const fields = 23
8989 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9090 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
9191 }
@@ -83,9 +83,9 @@ func TestSceneSameAs(t *testing.T) {
83 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:83 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
84 // update Scene.SameAs, then this count.84 // update Scene.SameAs, then this count.
85 func TestSceneSameAsCoversEveryField(t *testing.T) {85 func TestSceneSameAsCoversEveryField(t *testing.T) {
86- // 22 with Animating, which SameAs intentionally does not compare: it86+ // 23 with Animating, which SameAs intentionally does not compare: it
87 // only schedules the next frame, it draws nothing.87 // only schedules the next frame, it draws nothing.
88- const fields = 2288+ const fields = 23
89 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),89 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
90 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")90 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
91 }91 }

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close