Merge 2117e03a08536cead35d1280676b7a8517276e54 into dc5dff61334a51096a0e2c54e3d44b3b0aa6540c
49 files · +1246 -834
M README.md +8 -3
| @@ -225,9 +225,10 @@ cursor.hide-after = 5s | |||
| 225 | 225 | focus.follow-move = off | |
| 226 | 226 | animations = off | |
| 227 | 227 | 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 | |
| 231 | 232 | ||
| 232 | 233 | # Rendering and logging | |
| 233 | 234 | render.direct-scanout = on | |
| @@ -324,6 +325,10 @@ bind.cmd+code:13 = scale-up | |||
| 324 | 325 | bind.cmd+code:12 = scale-down | |
| 325 | 326 | ``` | |
| 326 | 327 | ||
| 328 | + | The focus flash marks the window, column or output an action changes with a brief | |
| 329 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |
| 330 | + | It runs when `animations = on`. | |
| 331 | + | ||
| 327 | 332 | Per-output defaults (replace `<name>` with a connector such as `DP-1`): | |
| 328 | 333 | ||
| 329 | 334 | ```text | |
| @@ -225,9 +225,10 @@ cursor.hide-after = 5s | |||||
| 225 | focus.follow-move = off | 225 | focus.follow-move = off | ||
| 226 | animations = off | 226 | animations = off | ||
| 227 | animations.speed = normal | 227 | animations.speed = normal | ||
| 228 | - | focus.animation = pulse | 228 | + | focus.flash = on |
| 229 | - | focus.effect = screen | 229 | + | focus.flash-width = 2 |
| 230 | - | focus.strength = 0.04 | 230 | + | focus.flash-opacity = 0.6 |
| 231 | + | focus.flash-duration = 200ms | |||
| 231 | 232 | ||||
| 232 | # Rendering and logging | 233 | # Rendering and logging | ||
| 233 | render.direct-scanout = on | 234 | render.direct-scanout = on | ||
| @@ -324,6 +325,10 @@ bind.cmd+code:13 = scale-up | |||||
| 324 | bind.cmd+code:12 = scale-down | 325 | bind.cmd+code:12 = scale-down | ||
| 325 | ``` | 326 | ``` | ||
| 326 | 327 | ||||
| 328 | + | The focus flash marks the window, column or output an action changes with a brief | |||
| 329 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |||
| 330 | + | It runs when `animations = on`. | |||
| 331 | + | ||||
| 327 | Per-output defaults (replace `<name>` with a connector such as `DP-1`): | 332 | Per-output defaults (replace `<name>` with a connector such as `DP-1`): | ||
| 328 | 333 | ||||
| 329 | ```text | 334 | ```text | ||
M docs/config.md +9 -2
| @@ -50,8 +50,11 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 50 | 50 | | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers | | |
| 51 | 51 | | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` | | |
| 52 | 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` | | |
| 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` | | |
| 55 | 58 | | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace | | |
| 56 | 59 | | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` | | |
| 57 | 60 | | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout | | |
| @@ -74,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 74 | 77 | ||
| 75 | 78 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | |
| 76 | 79 | ||
| 80 | + | The focus flash marks the window, column or output an action changes with a brief | |
| 81 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |
| 82 | + | It runs when `animations = on`. | |
| 83 | + | ||
| 77 | 84 | ## Window rules | |
| 78 | 85 | ||
| 79 | 86 | Rules place an app's windows when they first open. | |
| @@ -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 | | ||
| @@ -74,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 74 | 77 | ||||
| 75 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | 78 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | ||
| 76 | 79 | ||||
| 80 | + | The focus flash marks the window, column or output an action changes with a brief | |||
| 81 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |||
| 82 | + | It runs when `animations = on`. | |||
| 83 | + | ||||
| 77 | ## Window rules | 84 | ## Window rules | ||
| 78 | 85 | ||||
| 79 | Rules place an app's windows when they first open. | 86 | Rules place an app's windows when they first open. | ||
M docs/headless.md +2 -0
| @@ -8,6 +8,8 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 8 | 8 | ``` | |
| 9 | 9 | ||
| 10 | 10 | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | |
| 11 | + | - `--size 640x360,640x360` creates two outputs, arranged left to right, | |
| 12 | + | for testing `focus-monitor-left/right` and moving columns between outputs. | |
| 11 | 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | |
| 12 | 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | |
| 13 | 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | |
| @@ -8,6 +8,8 @@ WAYLAND_DISPLAY=<name from the log> foot | |||||
| 8 | ``` | 8 | ``` | ||
| 9 | 9 | ||||
| 10 | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | 10 | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | ||
| 11 | + | - `--size 640x360,640x360` creates two outputs, arranged left to right, | |||
| 12 | + | for testing `focus-monitor-left/right` and moving columns between outputs. | |||
| 11 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | ||
| 12 | - `--timeout 5s` stops NeferWL after 5 seconds. | 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | ||
| 13 | - `grim` and recorders work too; see [Screen capture](capture.md). | 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | ||
M examples/config +9 -6
| @@ -85,12 +85,15 @@ focus.follow-move = off | |||
| 85 | 85 | # (10x faster). | |
| 86 | 86 | animations = off | |
| 87 | 87 | 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 = | |
| 94 | 97 | ||
| 95 | 98 | # Named workspaces are outside the numbered list and require a bind. The bind | |
| 96 | 99 | # 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 = off | 86 | animations = off | ||
| 87 | animations.speed = normal | 87 | 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 strength | 89 | + | # 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 = pulse | 91 | + | # color #rrggbb (empty: border.active). |
| 92 | - | focus.effect = screen | 92 | + | focus.flash = on |
| 93 | - | focus.strength = 0.04 | 93 | + | 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 bind | 98 | # 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 { | |||
| 184 | 184 | // Animations are experimental: off unless the config turns them on. | |
| 185 | 185 | c.Animations.On = false | |
| 186 | 186 | 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 | |
| 190 | 191 | c.Binds = map[string]string{} | |
| 191 | 192 | for _, b := range defaultBinds { | |
| 192 | 193 | combos := []string{b.combo} | |
| @@ -840,23 +841,35 @@ func set(c *ports.Config, key, v string) error { | |||
| 840 | 841 | return err | |
| 841 | 842 | } | |
| 842 | 843 | 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": | |
| 855 | 857 | 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)") | |
| 858 | 871 | } | |
| 859 | - | c.Focus.Strength = f | |
| 872 | + | c.Focus.FlashColor = v | |
| 860 | 873 | case "render.direct-scanout": | |
| 861 | 874 | b, err := onOff(v) | |
| 862 | 875 | 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 = false | 185 | c.Animations.On = false | ||
| 186 | c.Animations.Slowdown = normalSlowdown | 186 | c.Animations.Slowdown = normalSlowdown | ||
| 187 | - | c.Focus.Animation = ports.FocusAnimationPulse | 187 | + | c.Focus.Flash = true |
| 188 | - | c.Focus.Effect = ports.FocusEffectScreen | 188 | + | c.Focus.FlashWidth = 2 |
| 189 | - | c.Focus.Strength = 0.04 | 189 | + | 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 err | 841 | return err | ||
| 841 | } | 842 | } | ||
| 842 | c.Focus.FollowMove = b | 843 | 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 = v | 848 | + | } |
| 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 = v | 853 | + | 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 = f | 872 | + | 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) { | |||
| 110 | 110 | } | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | func TestFocusIndicator(t *testing.T) { | |
| 113 | + | func TestFocusFlash(t *testing.T) { | |
| 114 | 114 | 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) | |
| 117 | 117 | } | |
| 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 { | |
| 120 | 122 | t.Fatal(c.Focus, w) | |
| 121 | 123 | } | |
| 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 | + | } { | |
| 124 | 138 | c, w = parseString(t, line+"\n") | |
| 125 | 139 | if c.Focus != d || len(w) != 1 { | |
| 126 | 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 | ||
| @@ -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().Focus | 114 | 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 | ||||
A internal/adapters/drm/focus_flash_test.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/stretchr/testify/require" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFocusFlashRequiresComposition(t *testing.T) { | |
| 10 | + | s, contents := fullscreenScene() | |
| 11 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |
| 12 | + | _, reason := scanoutCandidate(s, contents, 200, 100, nil) | |
| 13 | + | require.Equal(t, "focus_flash", reason) | |
| 14 | + | s, contents = overlayScene() | |
| 15 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |
| 16 | + | _, _, _, reason = overlayCandidate(s, contents, false, nil, nil) | |
| 17 | + | require.Equal(t, "focus_flash", reason) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/stretchr/testify/require" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFocusFlashRequiresComposition(t *testing.T) { | |||
| 10 | + | s, contents := fullscreenScene() | |||
| 11 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |||
| 12 | + | _, reason := scanoutCandidate(s, contents, 200, 100, nil) | |||
| 13 | + | require.Equal(t, "focus_flash", reason) | |||
| 14 | + | s, contents = overlayScene() | |||
| 15 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |||
| 16 | + | _, _, _, reason = overlayCandidate(s, contents, false, nil, nil) | |||
| 17 | + | require.Equal(t, "focus_flash", reason) | |||
| 18 | + | } | |||
M internal/adapters/drm/overlay.go +7 -3
| @@ -66,6 +66,10 @@ type overlayWin struct { | |||
| 66 | 66 | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | |
| 67 | 67 | // composed, never put on the plane. | |
| 68 | 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | |
| 69 | + | // A hardware plane would bypass the compositor-owned flash border. | |
| 70 | + | if s.Flash != nil { | |
| 71 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |
| 72 | + | } | |
| 69 | 73 | if s.Transform != 0 { | |
| 70 | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | |
| 71 | 75 | } | |
| @@ -93,9 +97,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 93 | 97 | // buffer, and so is a colour the plane cannot show (raw PQ values | |
| 94 | 98 | // on an SDR output, SDR on HDR without a pipeline). A dimmed | |
| 95 | 99 | // window (a peeking stashed one) needs the veil drawn over it, a | |
| 96 | - | // fading one its translucency, and one with the focus effect its | |
| 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)) { | |
| 100 | + | // fading one its translucency; an overview preview is drawn smaller | |
| 101 | + | // than its buffer. | |
| 102 | + | 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 | 103 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 100 | 104 | pick, pickAt, mode = w, i, m | |
| 101 | 105 | continue | |
| @@ -66,6 +66,10 @@ type overlayWin struct { | |||||
| 66 | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | 66 | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | ||
| 67 | // composed, never put on the plane. | 67 | // composed, never put on the plane. | ||
| 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | ||
| 69 | + | // A hardware plane would bypass the compositor-owned flash border. | |||
| 70 | + | if s.Flash != nil { | |||
| 71 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |||
| 72 | + | } | |||
| 69 | if s.Transform != 0 { | 73 | if s.Transform != 0 { | ||
| 70 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | ||
| 71 | } | 75 | } | ||
| @@ -93,9 +97,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 values | 97 | // 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 dimmed | 98 | // on an SDR output, SDR on HDR without a pipeline). A dimmed | ||
| 95 | // window (a peeking stashed one) needs the veil drawn over it, a | 99 | // window (a peeking stashed one) needs the veil drawn over it, a | ||
| 96 | - | // fading one its translucency, and one with the focus effect its | 100 | + | // fading one its translucency; an overview preview is drawn smaller |
| 97 | - | // lift; an overview preview is drawn smaller than its buffer. | 101 | + | // 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)) { | 102 | + | 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 == "" { | 103 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | ||
| 100 | pick, pickAt, mode = w, i, m | 104 | pick, pickAt, mode = w, i, m | ||
| 101 | continue | 105 | continue | ||
M internal/adapters/drm/overlay_test.go +0 -6
| @@ -156,12 +156,6 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 156 | 156 | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 157 | 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 | 159 | // An overview preview is drawn smaller than its buffer: composed. | |
| 166 | 160 | s, _ = overlayScene() | |
| 167 | 161 | 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.5 | 161 | s.Windows[len(s.Windows)-1].Preview = 0.5 | ||
M internal/adapters/drm/scanout.go +4 -0
| @@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 90 | 90 | // closed and fades out): the client may already have its buffer back, so | |
| 91 | 91 | // it is never put on a plane (nil: none is). | |
| 92 | 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | |
| 93 | + | // A hardware plane would bypass the compositor-owned flash border. | |
| 94 | + | if s.Flash != nil { | |
| 95 | + | return ports.SurfaceContent{}, "focus_flash" | |
| 96 | + | } | |
| 93 | 97 | if s.Transform != 0 { | |
| 94 | 98 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 95 | 99 | return ports.SurfaceContent{}, "output_transform" | |
| @@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||||
| 90 | // closed and fades out): the client may already have its buffer back, so | 90 | // closed and fades out): the client may already have its buffer back, so | ||
| 91 | // it is never put on a plane (nil: none is). | 91 | // it is never put on a plane (nil: none is). | ||
| 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | ||
| 93 | + | // A hardware plane would bypass the compositor-owned flash border. | |||
| 94 | + | if s.Flash != nil { | |||
| 95 | + | return ports.SurfaceContent{}, "focus_flash" | |||
| 96 | + | } | |||
| 93 | if s.Transform != 0 { | 97 | if s.Transform != 0 { | ||
| 94 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | 98 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | ||
| 95 | return ports.SurfaceContent{}, "output_transform" | 99 | return ports.SurfaceContent{}, "output_transform" | ||
M internal/adapters/vulkan/damage.go +8 -0
| @@ -286,6 +286,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||
| 286 | 286 | return image.Rectangle{}, false | |
| 287 | 287 | } | |
| 288 | 288 | var area image.Rectangle | |
| 289 | + | if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) { | |
| 290 | + | if old.Flash != nil { | |
| 291 | + | area = area.Union(phys(old.Flash.Rect)) | |
| 292 | + | } | |
| 293 | + | if cur.Flash != nil { | |
| 294 | + | area = area.Union(phys(cur.Flash.Rect)) | |
| 295 | + | } | |
| 296 | + | } | |
| 289 | 297 | for i := range old.Windows { | |
| 290 | 298 | a, b := old.Windows[i], cur.Windows[i] | |
| 291 | 299 | if a.ID != b.ID { | |
| @@ -286,6 +286,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||||
| 286 | return image.Rectangle{}, false | 286 | return image.Rectangle{}, false | ||
| 287 | } | 287 | } | ||
| 288 | var area image.Rectangle | 288 | var area image.Rectangle | ||
| 289 | + | if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) { | |||
| 290 | + | if old.Flash != nil { | |||
| 291 | + | area = area.Union(phys(old.Flash.Rect)) | |||
| 292 | + | } | |||
| 293 | + | if cur.Flash != nil { | |||
| 294 | + | area = area.Union(phys(cur.Flash.Rect)) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 289 | for i := range old.Windows { | 297 | for i := range old.Windows { | ||
| 290 | a, b := old.Windows[i], cur.Windows[i] | 298 | a, b := old.Windows[i], cur.Windows[i] | ||
| 291 | if a.ID != b.ID { | 299 | if a.ID != b.ID { | ||
M internal/adapters/vulkan/damage_delta_test.go +36 -4
| @@ -17,9 +17,9 @@ import ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | 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 | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 22 | |
| 22 | + | const fields = 23 | |
| 23 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 24 | 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 | 28 | // sceneDelta compares windows by struct equality, so a new field is covered | |
| 29 | 29 | // as long as it only changes where or how that window draws (inside Rect). | |
| 30 | 30 | func TestSceneWindowFieldCount(t *testing.T) { | |
| 31 | - | const fields = 15 | |
| 31 | + | const fields = 14 | |
| 32 | 32 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))), | |
| 33 | 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 | 75 | func TestDamageDeltaWindowFieldChange(t *testing.T) { | |
| 76 | 76 | old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |
| 77 | 77 | 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 | |
| 79 | 79 | d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |
| 80 | 80 | require.False(t, d.all()) | |
| 81 | 81 | assert.Equal(t, image.Rect(0, 0, 100, 100), d.area) | |
| @@ -407,3 +407,35 @@ func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) { | |||
| 407 | 407 | float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1 | |
| 408 | 408 | assert.True(t, newDamage(held(gone), float, bounds).all()) | |
| 409 | 409 | } | |
| 410 | + | ||
| 411 | + | func TestSceneDeltaFlashRectDamage(t *testing.T) { | |
| 412 | + | a := &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 80}, Width: 3, Color: "#ff0000", Alpha: 1} | |
| 413 | + | faded := *a | |
| 414 | + | faded.Alpha = 0.5 | |
| 415 | + | moved := faded | |
| 416 | + | moved.Rect.X = 150 | |
| 417 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X*2, r.Y*2, (r.X+r.W)*2, (r.Y+r.H)*2) } | |
| 418 | + | for _, tc := range []struct { | |
| 419 | + | name string | |
| 420 | + | old, cur *ports.FocusFlash | |
| 421 | + | want image.Rectangle | |
| 422 | + | }{ | |
| 423 | + | {"appears", nil, a, phys(a.Rect)}, | |
| 424 | + | {"alpha", a, &faded, phys(a.Rect)}, | |
| 425 | + | {"moves", a, &moved, phys(a.Rect).Union(phys(moved.Rect))}, | |
| 426 | + | {"removed", a, nil, phys(a.Rect)}, | |
| 427 | + | {"idle identical", a, a, image.Rectangle{}}, | |
| 428 | + | } { | |
| 429 | + | t.Run(tc.name, func(t *testing.T) { | |
| 430 | + | old, cur := deltaScene(1), deltaScene(2) | |
| 431 | + | old.Flash, cur.Flash = tc.old, tc.cur | |
| 432 | + | area, ok := sceneDelta(old, cur, phys, 1000000) | |
| 433 | + | require.True(t, ok) | |
| 434 | + | assert.Equal(t, tc.want, area) | |
| 435 | + | }) | |
| 436 | + | } | |
| 437 | + | old, cur := deltaScene(1), deltaScene(2) | |
| 438 | + | cur.Flash = a | |
| 439 | + | _, ok := sceneDelta(old, cur, phys, 1) | |
| 440 | + | require.False(t, ok, "large damage must retain full-redraw fallback") | |
| 441 | + | } | |
| @@ -17,9 +17,9 @@ import ( | |||||
| 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | 17 | // 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 only | 20 | + | // 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 = 22 | 22 | + | 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 covered | 28 | // 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 = 15 | 31 | + | 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.3 | 78 | + | 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) | ||
| @@ -407,3 +407,35 @@ func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) { | |||||
| 407 | float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1 | 407 | float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1 | ||
| 408 | assert.True(t, newDamage(held(gone), float, bounds).all()) | 408 | assert.True(t, newDamage(held(gone), float, bounds).all()) | ||
| 409 | } | 409 | } | ||
| 410 | + | ||||
| 411 | + | func TestSceneDeltaFlashRectDamage(t *testing.T) { | |||
| 412 | + | a := &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 80}, Width: 3, Color: "#ff0000", Alpha: 1} | |||
| 413 | + | faded := *a | |||
| 414 | + | faded.Alpha = 0.5 | |||
| 415 | + | moved := faded | |||
| 416 | + | moved.Rect.X = 150 | |||
| 417 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X*2, r.Y*2, (r.X+r.W)*2, (r.Y+r.H)*2) } | |||
| 418 | + | for _, tc := range []struct { | |||
| 419 | + | name string | |||
| 420 | + | old, cur *ports.FocusFlash | |||
| 421 | + | want image.Rectangle | |||
| 422 | + | }{ | |||
| 423 | + | {"appears", nil, a, phys(a.Rect)}, | |||
| 424 | + | {"alpha", a, &faded, phys(a.Rect)}, | |||
| 425 | + | {"moves", a, &moved, phys(a.Rect).Union(phys(moved.Rect))}, | |||
| 426 | + | {"removed", a, nil, phys(a.Rect)}, | |||
| 427 | + | {"idle identical", a, a, image.Rectangle{}}, | |||
| 428 | + | } { | |||
| 429 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 430 | + | old, cur := deltaScene(1), deltaScene(2) | |||
| 431 | + | old.Flash, cur.Flash = tc.old, tc.cur | |||
| 432 | + | area, ok := sceneDelta(old, cur, phys, 1000000) | |||
| 433 | + | require.True(t, ok) | |||
| 434 | + | assert.Equal(t, tc.want, area) | |||
| 435 | + | }) | |||
| 436 | + | } | |||
| 437 | + | old, cur := deltaScene(1), deltaScene(2) | |||
| 438 | + | cur.Flash = a | |||
| 439 | + | _, ok := sceneDelta(old, cur, phys, 1) | |||
| 440 | + | require.False(t, ok, "large damage must retain full-redraw fallback") | |||
| 441 | + | } | |||
A internal/adapters/vulkan/flash_perf_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func BenchmarkFlashSceneWalk(b *testing.B) { | |
| 11 | + | r := &Renderer{width: 1000, height: 600} | |
| 12 | + | w := sceneWalk{r: r, s: ports.Scene{Flash: &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 10, W: 300, H: 200}, Width: 4, Color: "#ff0000", Alpha: 0.5}}, scale: 1, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, 1000, 600), draws: make([]draw, 0, 4)} | |
| 13 | + | b.ReportAllocs() | |
| 14 | + | b.ResetTimer() | |
| 15 | + | for range b.N { | |
| 16 | + | w.draws = w.draws[:0] | |
| 17 | + | w.focusFlash() | |
| 18 | + | } | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func BenchmarkFlashSceneWalk(b *testing.B) { | |||
| 11 | + | r := &Renderer{width: 1000, height: 600} | |||
| 12 | + | w := sceneWalk{r: r, s: ports.Scene{Flash: &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 10, W: 300, H: 200}, Width: 4, Color: "#ff0000", Alpha: 0.5}}, scale: 1, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, 1000, 600), draws: make([]draw, 0, 4)} | |||
| 13 | + | b.ReportAllocs() | |||
| 14 | + | b.ResetTimer() | |||
| 15 | + | for range b.N { | |||
| 16 | + | w.draws = w.draws[:0] | |||
| 17 | + | w.focusFlash() | |||
| 18 | + | } | |||
| 19 | + | } | |||
A internal/adapters/vulkan/focus_flash_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/stretchr/testify/require" | |
| 6 | + | "image" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFocusFlashGeometryAlphaAndOrder(t *testing.T) { | |
| 11 | + | for _, scale := range []int{1, 2} { | |
| 12 | + | r := &Renderer{width: 128, height: 128} | |
| 13 | + | s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"}, | |
| 14 | + | Separators: []ports.Separator{{Rect: ports.Rect{W: 2, H: 20}, Active: true}}, | |
| 15 | + | Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 0.5}, | |
| 16 | + | CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}} | |
| 17 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |
| 18 | + | require.Len(t, ds, 9) | |
| 19 | + | require.Equal(t, [4]float32{0, 1, 0, 1}, ds[0].pc.color) | |
| 20 | + | for i, rect := range [][4]int32{{8, 4, 24, 6}, {8, 10, 24, 12}, {8, 6, 10, 10}, {22, 6, 24, 10}} { | |
| 21 | + | for j := range rect { | |
| 22 | + | rect[j] *= int32(scale) | |
| 23 | + | } | |
| 24 | + | require.Equal(t, rect, ds[i+1].pc.rect) | |
| 25 | + | require.Equal(t, float32(0.5), ds[i+1].pc.color[3]) | |
| 26 | + | require.Greater(t, ds[i+1].pc.color[0], float32(0)) | |
| 27 | + | require.Zero(t, ds[i+1].pc.color[1]) | |
| 28 | + | } | |
| 29 | + | require.Equal(t, float32(1), ds[5].pc.color[3], "capture indicator remains opaque and above flash") | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestFocusFlashAbsentEmptyAndThin(t *testing.T) { | |
| 34 | + | r := &Renderer{width: 64, height: 64} | |
| 35 | + | for _, f := range []*ports.FocusFlash{nil, {Alpha: 0}, {Alpha: -1}, {Alpha: 1, Rect: ports.Rect{W: 0, H: 10}}} { | |
| 36 | + | s := ports.Scene{Scale: 1, Flash: f} | |
| 37 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |
| 38 | + | } | |
| 39 | + | for _, width := range []int{0, 8} { | |
| 40 | + | s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Alpha: 1, Width: width, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 10}}} | |
| 41 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |
| 42 | + | require.Len(t, ds, 1) | |
| 43 | + | require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect) | |
| 44 | + | } | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/stretchr/testify/require" | |||
| 6 | + | "image" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFocusFlashGeometryAlphaAndOrder(t *testing.T) { | |||
| 11 | + | for _, scale := range []int{1, 2} { | |||
| 12 | + | r := &Renderer{width: 128, height: 128} | |||
| 13 | + | s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"}, | |||
| 14 | + | Separators: []ports.Separator{{Rect: ports.Rect{W: 2, H: 20}, Active: true}}, | |||
| 15 | + | Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 0.5}, | |||
| 16 | + | CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}} | |||
| 17 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |||
| 18 | + | require.Len(t, ds, 9) | |||
| 19 | + | require.Equal(t, [4]float32{0, 1, 0, 1}, ds[0].pc.color) | |||
| 20 | + | for i, rect := range [][4]int32{{8, 4, 24, 6}, {8, 10, 24, 12}, {8, 6, 10, 10}, {22, 6, 24, 10}} { | |||
| 21 | + | for j := range rect { | |||
| 22 | + | rect[j] *= int32(scale) | |||
| 23 | + | } | |||
| 24 | + | require.Equal(t, rect, ds[i+1].pc.rect) | |||
| 25 | + | require.Equal(t, float32(0.5), ds[i+1].pc.color[3]) | |||
| 26 | + | require.Greater(t, ds[i+1].pc.color[0], float32(0)) | |||
| 27 | + | require.Zero(t, ds[i+1].pc.color[1]) | |||
| 28 | + | } | |||
| 29 | + | require.Equal(t, float32(1), ds[5].pc.color[3], "capture indicator remains opaque and above flash") | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestFocusFlashAbsentEmptyAndThin(t *testing.T) { | |||
| 34 | + | r := &Renderer{width: 64, height: 64} | |||
| 35 | + | for _, f := range []*ports.FocusFlash{nil, {Alpha: 0}, {Alpha: -1}, {Alpha: 1, Rect: ports.Rect{W: 0, H: 10}}} { | |||
| 36 | + | s := ports.Scene{Scale: 1, Flash: f} | |||
| 37 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |||
| 38 | + | } | |||
| 39 | + | for _, width := range []int{0, 8} { | |||
| 40 | + | s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Alpha: 1, Width: width, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 10}}} | |||
| 41 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |||
| 42 | + | require.Len(t, ds, 1) | |||
| 43 | + | require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect) | |||
| 44 | + | } | |||
| 45 | + | } | |||
M internal/adapters/vulkan/hdr_composition_test.go +0 -19
| @@ -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 | 262 | func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) { | |
| 282 | 263 | r := hdrTestRenderer(t) | |
| 283 | 264 | 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) { | |||
| 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 | 114 | func TestRendererContents(t *testing.T) { | |
| 139 | 115 | r, err := New(64, 48) | |
| 140 | 116 | 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 +28 -18
| @@ -20,9 +20,6 @@ type sceneWalk struct { | |||
| 20 | 20 | // zoom shrinks the surfaces of the window being placed: an overview | |
| 21 | 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | |
| 22 | 22 | zoom float64 | |
| 23 | - | // pulse is the focus effect of the window being placed | |
| 24 | - | // (ports.SceneWindow.FocusEffect); 0 otherwise. | |
| 25 | - | pulse float32 | |
| 26 | 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | |
| 27 | 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | |
| 28 | 25 | // it (premultiplied); 1 otherwise. | |
| @@ -61,11 +58,37 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 61 | 58 | w.bounds = outputBounds | |
| 62 | 59 | w.layers(true) | |
| 63 | 60 | w.popups(true) | |
| 61 | + | w.focusFlash() | |
| 64 | 62 | w.captureIndicators() | |
| 65 | 63 | r.scratchDraws = w.draws | |
| 66 | 64 | return w.draws | |
| 67 | 65 | } | |
| 68 | 66 | ||
| 67 | + | // focusFlash draws an inner border in logical coordinates, above the scene | |
| 68 | + | // but below capture indicators. Restore alpha so later overlays stay opaque. | |
| 69 | + | func (w *sceneWalk) focusFlash() { | |
| 70 | + | f := w.s.Flash | |
| 71 | + | if f == nil || f.Alpha <= 0 || f.Rect.W <= 0 || f.Rect.H <= 0 { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | alpha := w.alpha | |
| 75 | + | w.alpha = float32(f.Alpha) | |
| 76 | + | w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color)) | |
| 77 | + | w.alpha = alpha | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // innerBorder draws four non-overlapping strips, or fills a thin target. | |
| 81 | + | func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) { | |
| 82 | + | if b <= 0 { | |
| 83 | + | w.fill(w.physRect(t.X, t.Y, t.W, t.H), col) | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |
| 87 | + | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |
| 88 | + | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |
| 89 | + | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |
| 90 | + | } | |
| 91 | + | ||
| 69 | 92 | // captureIndicators draws the compositor's capture indicator over everything | |
| 70 | 93 | // (ports.CaptureIndicator). The border is four fills on the inside edge of the | |
| 71 | 94 | // target, so it stays visible on a monitor edge; the pill is a small rounded | |
| @@ -86,17 +109,7 @@ func (w *sceneWalk) captureIndicators() { | |||
| 86 | 109 | w.pill(w.physRect(t.X, t.Y, t.W, t.H), col) | |
| 87 | 110 | continue | |
| 88 | 111 | } | |
| 89 | - | b := ports.CaptureBorderOf(t.W, t.H) | |
| 90 | - | if b <= 0 { | |
| 91 | - | // Too thin for a border (core inflates such marks, so this is a | |
| 92 | - | // 1-pixel output): a listed mark always draws something. | |
| 93 | - | w.fill(w.physRect(t.X, t.Y, t.W, t.H), col) | |
| 94 | - | continue | |
| 95 | - | } | |
| 96 | - | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |
| 97 | - | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |
| 98 | - | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |
| 99 | - | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |
| 112 | + | w.innerBorder(t, ports.CaptureBorderOf(t.W, t.H), col) | |
| 100 | 113 | } | |
| 101 | 114 | } | |
| 102 | 115 | ||
| @@ -251,9 +264,8 @@ func (w *sceneWalk) windows() { | |||
| 251 | 264 | } else if win.Preview > 0 { | |
| 252 | 265 | w.zoom = win.Preview | |
| 253 | 266 | } | |
| 254 | - | w.pulse = float32(max(0, min(win.FocusEffect, 1))) | |
| 255 | 267 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | |
| 256 | - | w.zoom, w.pulse = 1, 0 | |
| 268 | + | w.zoom = 1 | |
| 257 | 269 | } | |
| 258 | 270 | if win.Floating { | |
| 259 | 271 | w.separators(win.ID) | |
| @@ -543,7 +555,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 543 | 555 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque) | |
| 544 | 556 | r.setContentColor(&dr, content) | |
| 545 | 557 | dr.pc.color[3] *= w.alpha | |
| 546 | - | dr.pc.mapy[2] = w.pulse | |
| 547 | 558 | if im.yuv { | |
| 548 | 559 | dr.pc.misc[1] |= flagYUV | |
| 549 | 560 | if content.DMABuf.Format == fourcc('P', '0', '1', '0') { | |
| @@ -590,7 +601,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 590 | 601 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque) | |
| 591 | 602 | r.setContentColor(&dr, content) | |
| 592 | 603 | dr.pc.color[3] *= w.alpha | |
| 593 | - | dr.pc.mapy[2] = w.pulse | |
| 594 | 604 | dr.shm = c | |
| 595 | 605 | dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0} | |
| 596 | 606 | w.draws = append(w.draws, dr) | |
| @@ -20,9 +20,6 @@ type sceneWalk struct { | |||||
| 20 | // zoom shrinks the surfaces of the window being placed: an overview | 20 | // zoom shrinks the surfaces of the window being placed: an overview | ||
| 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | ||
| 22 | zoom float64 | 22 | zoom float64 | ||
| 23 | - | // pulse is the focus effect of the window being placed | |||
| 24 | - | // (ports.SceneWindow.FocusEffect); 0 otherwise. | |||
| 25 | - | pulse float32 | |||
| 26 | // alpha is the opacity of the window being drawn, 1 - its Fade | 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | ||
| 27 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | ||
| 28 | // it (premultiplied); 1 otherwise. | 25 | // it (premultiplied); 1 otherwise. | ||
| @@ -61,11 +58,37 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 61 | w.bounds = outputBounds | 58 | w.bounds = outputBounds | ||
| 62 | w.layers(true) | 59 | w.layers(true) | ||
| 63 | w.popups(true) | 60 | w.popups(true) | ||
| 61 | + | w.focusFlash() | |||
| 64 | w.captureIndicators() | 62 | w.captureIndicators() | ||
| 65 | r.scratchDraws = w.draws | 63 | r.scratchDraws = w.draws | ||
| 66 | return w.draws | 64 | return w.draws | ||
| 67 | } | 65 | } | ||
| 68 | 66 | ||||
| 67 | + | // focusFlash draws an inner border in logical coordinates, above the scene | |||
| 68 | + | // but below capture indicators. Restore alpha so later overlays stay opaque. | |||
| 69 | + | func (w *sceneWalk) focusFlash() { | |||
| 70 | + | f := w.s.Flash | |||
| 71 | + | if f == nil || f.Alpha <= 0 || f.Rect.W <= 0 || f.Rect.H <= 0 { | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | alpha := w.alpha | |||
| 75 | + | w.alpha = float32(f.Alpha) | |||
| 76 | + | w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color)) | |||
| 77 | + | w.alpha = alpha | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // innerBorder draws four non-overlapping strips, or fills a thin target. | |||
| 81 | + | func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) { | |||
| 82 | + | if b <= 0 { | |||
| 83 | + | w.fill(w.physRect(t.X, t.Y, t.W, t.H), col) | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |||
| 87 | + | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |||
| 88 | + | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |||
| 89 | + | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 69 | // captureIndicators draws the compositor's capture indicator over everything | 92 | // captureIndicators draws the compositor's capture indicator over everything | ||
| 70 | // (ports.CaptureIndicator). The border is four fills on the inside edge of the | 93 | // (ports.CaptureIndicator). The border is four fills on the inside edge of the | ||
| 71 | // target, so it stays visible on a monitor edge; the pill is a small rounded | 94 | // target, so it stays visible on a monitor edge; the pill is a small rounded | ||
| @@ -86,17 +109,7 @@ func (w *sceneWalk) captureIndicators() { | |||||
| 86 | w.pill(w.physRect(t.X, t.Y, t.W, t.H), col) | 109 | w.pill(w.physRect(t.X, t.Y, t.W, t.H), col) | ||
| 87 | continue | 110 | continue | ||
| 88 | } | 111 | } | ||
| 89 | - | b := ports.CaptureBorderOf(t.W, t.H) | 112 | + | w.innerBorder(t, ports.CaptureBorderOf(t.W, t.H), col) |
| 90 | - | if b <= 0 { | |||
| 91 | - | // Too thin for a border (core inflates such marks, so this is a | |||
| 92 | - | // 1-pixel output): a listed mark always draws something. | |||
| 93 | - | w.fill(w.physRect(t.X, t.Y, t.W, t.H), col) | |||
| 94 | - | continue | |||
| 95 | - | } | |||
| 96 | - | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |||
| 97 | - | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |||
| 98 | - | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |||
| 99 | - | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |||
| 100 | } | 113 | } | ||
| 101 | } | 114 | } | ||
| 102 | 115 | ||||
| @@ -251,9 +264,8 @@ func (w *sceneWalk) windows() { | |||||
| 251 | } else if win.Preview > 0 { | 264 | } else if win.Preview > 0 { | ||
| 252 | w.zoom = win.Preview | 265 | w.zoom = win.Preview | ||
| 253 | } | 266 | } | ||
| 254 | - | w.pulse = float32(max(0, min(win.FocusEffect, 1))) | |||
| 255 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | 267 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | ||
| 256 | - | w.zoom, w.pulse = 1, 0 | 268 | + | w.zoom = 1 |
| 257 | } | 269 | } | ||
| 258 | if win.Floating { | 270 | if win.Floating { | ||
| 259 | w.separators(win.ID) | 271 | w.separators(win.ID) | ||
| @@ -543,7 +555,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) | 555 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque) | ||
| 544 | r.setContentColor(&dr, content) | 556 | r.setContentColor(&dr, content) | ||
| 545 | dr.pc.color[3] *= w.alpha | 557 | dr.pc.color[3] *= w.alpha | ||
| 546 | - | dr.pc.mapy[2] = w.pulse | |||
| 547 | if im.yuv { | 558 | if im.yuv { | ||
| 548 | dr.pc.misc[1] |= flagYUV | 559 | dr.pc.misc[1] |= flagYUV | ||
| 549 | if content.DMABuf.Format == fourcc('P', '0', '1', '0') { | 560 | if content.DMABuf.Format == fourcc('P', '0', '1', '0') { | ||
| @@ -590,7 +601,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) | 601 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque) | ||
| 591 | r.setContentColor(&dr, content) | 602 | r.setContentColor(&dr, content) | ||
| 592 | dr.pc.color[3] *= w.alpha | 603 | dr.pc.color[3] *= w.alpha | ||
| 593 | - | dr.pc.mapy[2] = w.pulse | |||
| 594 | dr.shm = c | 604 | dr.shm = c | ||
| 595 | dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0} | 605 | dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0} | ||
| 596 | w.draws = append(w.draws, dr) | 606 | w.draws = append(w.draws, dr) | ||
M internal/adapters/vulkan/shaders/compose.frag +0 -6
| @@ -146,12 +146,6 @@ void main() { | |||
| 146 | 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 | 149 | if (d.misc.x != modeSolid) { | |
| 156 | 150 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 157 | 151 | 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 @@ | |||
| 5 | 5 | struct Draw { | |
| 6 | 6 | ivec4 rect; // visible x0, y0, x1, y1 in target pixels | |
| 7 | 7 | 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 | |
| 9 | 9 | vec4 color; // solid premultiplied RGBA; content opacity in alpha | |
| 10 | 10 | vec4 crop; // source crop bounds in buffer pixels | |
| 11 | 11 | 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 pixels | 6 | ivec4 rect; // visible x0, y0, x1, y1 in target pixels | ||
| 7 | vec4 map; // source texel = map.xy + fragment centre.x * map.zw | 7 | 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 |
| 9 | vec4 color; // solid premultiplied RGBA; content opacity in alpha | 9 | vec4 color; // solid premultiplied RGBA; content opacity in alpha | ||
| 10 | vec4 crop; // source crop bounds in buffer pixels | 10 | vec4 crop; // source crop bounds in buffer pixels | ||
| 11 | uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w | 11 | 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() { | |||
| 141 | 141 | // ext_linear has the protocol's default 80 cd/m² reference white. | |
| 142 | 142 | // Keep negative and above-white values in fp16 until the HDR pass. | |
| 143 | 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 | 144 | vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) : | |
| 151 | 145 | (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v); | |
| 152 | 146 | 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) { | |||
| 176 | 176 | rects := make([]string, 0, len(s.Windows)) | |
| 177 | 177 | for _, w := range s.Windows { | |
| 178 | 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 | 180 | ev.Strs("windows", rects).Msg("scene") | |
| 185 | 181 | } | |
| @@ -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) { | |||
| 243 | 243 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 244 | 244 | cfg := config.Defaults() | |
| 245 | 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 | 246 | cfg.Animations.On = animated | |
| 249 | 247 | // The tap drops a scene it has no room for: a collector keeps up with | |
| 250 | 248 | // 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 = animated | 246 | cfg.Animations.On = animated | ||
| 249 | // The tap drops a scene it has no room for: a collector keeps up with | 247 | // 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 { | |||
| 13 | 13 | Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1, | |
| 14 | 14 | Windows: []ports.SceneWindow{ | |
| 15 | 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 | 18 | }, {Output: "B"}} | |
| 19 | 19 | } | |
| @@ -40,7 +40,7 @@ func TestLogScenesDebug(t *testing.T) { | |||
| 40 | 40 | for _, want := range []string{ | |
| 41 | 41 | `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`, | |
| 42 | 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 | 45 | if !strings.Contains(got, want) { | |
| 46 | 46 | 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/action.go +74 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "slices" | |
| 4 | 5 | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| @@ -213,6 +214,79 @@ type Effect struct { | |||
| 213 | 214 | // else along the layout's workspace axis; everything else applies to the | |
| 214 | 215 | // focused screen's monitor. | |
| 215 | 216 | func (c *Core) applyAction(a Action) Effect { | |
| 217 | + | if !c.flashOn() { | |
| 218 | + | return c.dispatchAction(a) | |
| 219 | + | } | |
| 220 | + | switch a { | |
| 221 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |
| 222 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating, | |
| 223 | + | ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, | |
| 224 | + | ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight, | |
| 225 | + | ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |
| 226 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 227 | + | default: | |
| 228 | + | return c.dispatchAction(a) | |
| 229 | + | } | |
| 230 | + | // Compare the action's visible result, not publication-time focus: pointer, | |
| 231 | + | // activation and mapping changes must never request a flash. | |
| 232 | + | beforeScreen := c.cur() | |
| 233 | + | beforeWS := beforeScreen.mon.Current() | |
| 234 | + | beforeID, _ := beforeScreen.mon.Focused() | |
| 235 | + | before := beforeScreen.mon.Layout() | |
| 236 | + | wasOverview := beforeScreen.mon.ov.open | |
| 237 | + | wasStashed := beforeWS.stashIndex(beforeID) >= 0 | |
| 238 | + | wasShown := beforeWS.stashShown() | |
| 239 | + | defer func() { | |
| 240 | + | sc := c.cur() | |
| 241 | + | id, _ := sc.mon.Focused() | |
| 242 | + | if sc.mon.ov.open { | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | if wasOverview { | |
| 246 | + | if a == ActionToggleOverview && id != 0 { | |
| 247 | + | c.requestFlash(flashWindow, id) | |
| 248 | + | } | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | level := flashWindow | |
| 252 | + | switch a { | |
| 253 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |
| 254 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating: | |
| 255 | + | case ActionToggleWindowStash: | |
| 256 | + | if !wasStashed { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | case ActionToggleStashVisible: | |
| 260 | + | if wasShown || !sc.mon.Current().stashShown() { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight: | |
| 264 | + | level = flashColumn | |
| 265 | + | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |
| 266 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 267 | + | if sc == beforeScreen { | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | c.requestFlash(flashMonitor, 0) | |
| 271 | + | return | |
| 272 | + | default: | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | if sc == beforeScreen && sc.mon.Current() != beforeWS { | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | after := sc.mon.Layout() | |
| 279 | + | changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool { | |
| 280 | + | return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating | |
| 281 | + | }) | |
| 282 | + | if changed && id != 0 { | |
| 283 | + | c.requestFlash(level, id) | |
| 284 | + | } | |
| 285 | + | }() | |
| 286 | + | return c.dispatchAction(a) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (c *Core) dispatchAction(a Action) Effect { | |
| 216 | 290 | if e, ok := c.cur().mon.overviewAction(a); ok { | |
| 217 | 291 | return e | |
| 218 | 292 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "slices" | |||
| 4 | "strconv" | 5 | "strconv" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | 7 | ||||
| @@ -213,6 +214,79 @@ type Effect struct { | |||||
| 213 | // else along the layout's workspace axis; everything else applies to the | 214 | // else along the layout's workspace axis; everything else applies to the | ||
| 214 | // focused screen's monitor. | 215 | // focused screen's monitor. | ||
| 215 | func (c *Core) applyAction(a Action) Effect { | 216 | func (c *Core) applyAction(a Action) Effect { | ||
| 217 | + | if !c.flashOn() { | |||
| 218 | + | return c.dispatchAction(a) | |||
| 219 | + | } | |||
| 220 | + | switch a { | |||
| 221 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |||
| 222 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating, | |||
| 223 | + | ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, | |||
| 224 | + | ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight, | |||
| 225 | + | ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |||
| 226 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |||
| 227 | + | default: | |||
| 228 | + | return c.dispatchAction(a) | |||
| 229 | + | } | |||
| 230 | + | // Compare the action's visible result, not publication-time focus: pointer, | |||
| 231 | + | // activation and mapping changes must never request a flash. | |||
| 232 | + | beforeScreen := c.cur() | |||
| 233 | + | beforeWS := beforeScreen.mon.Current() | |||
| 234 | + | beforeID, _ := beforeScreen.mon.Focused() | |||
| 235 | + | before := beforeScreen.mon.Layout() | |||
| 236 | + | wasOverview := beforeScreen.mon.ov.open | |||
| 237 | + | wasStashed := beforeWS.stashIndex(beforeID) >= 0 | |||
| 238 | + | wasShown := beforeWS.stashShown() | |||
| 239 | + | defer func() { | |||
| 240 | + | sc := c.cur() | |||
| 241 | + | id, _ := sc.mon.Focused() | |||
| 242 | + | if sc.mon.ov.open { | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | if wasOverview { | |||
| 246 | + | if a == ActionToggleOverview && id != 0 { | |||
| 247 | + | c.requestFlash(flashWindow, id) | |||
| 248 | + | } | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | level := flashWindow | |||
| 252 | + | switch a { | |||
| 253 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |||
| 254 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating: | |||
| 255 | + | case ActionToggleWindowStash: | |||
| 256 | + | if !wasStashed { | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | case ActionToggleStashVisible: | |||
| 260 | + | if wasShown || !sc.mon.Current().stashShown() { | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight: | |||
| 264 | + | level = flashColumn | |||
| 265 | + | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |||
| 266 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |||
| 267 | + | if sc == beforeScreen { | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 270 | + | c.requestFlash(flashMonitor, 0) | |||
| 271 | + | return | |||
| 272 | + | default: | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | if sc == beforeScreen && sc.mon.Current() != beforeWS { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | after := sc.mon.Layout() | |||
| 279 | + | changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool { | |||
| 280 | + | return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating | |||
| 281 | + | }) | |||
| 282 | + | if changed && id != 0 { | |||
| 283 | + | c.requestFlash(level, id) | |||
| 284 | + | } | |||
| 285 | + | }() | |||
| 286 | + | return c.dispatchAction(a) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func (c *Core) dispatchAction(a Action) Effect { | |||
| 216 | if e, ok := c.cur().mon.overviewAction(a); ok { | 290 | if e, ok := c.cur().mon.overviewAction(a); ok { | ||
| 217 | return e | 291 | return e | ||
| 218 | } | 292 | } | ||
M internal/core/core.go +11 -47
| @@ -155,9 +155,8 @@ type Core struct { | |||
| 155 | 155 | motionTime time.Duration | |
| 156 | 156 | pointerAt [2]float64 | |
| 157 | 157 | // capt is the capture state (capture.go, capindicator.go). | |
| 158 | - | capt captureState | |
| 159 | - | // pulse marks a window that just got the focus (pulse.go). | |
| 160 | - | pulse focusPulse | |
| 158 | + | capt captureState | |
| 159 | + | flash focusFlash | |
| 161 | 160 | // switcher is the column switcher in progress: the screen it is on and | |
| 162 | 161 | // the timer that shows its cards (switcher.go). | |
| 163 | 162 | switcher struct { | |
| @@ -532,14 +531,6 @@ func (v viewport) area(p Placement) Rect { | |||
| 532 | 531 | return v.usable | |
| 533 | 532 | } | |
| 534 | 533 | ||
| 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 | 534 | // shows reports whether the user sees p: not hidden, and overlapping its | |
| 544 | 535 | // area. | |
| 545 | 536 | func (v viewport) shows(p Placement) bool { | |
| @@ -636,9 +627,8 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||
| 636 | 627 | func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) } | |
| 637 | 628 | ||
| 638 | 629 | // 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. | |
| 630 | + | // moved the springs (nil: any, or none moved): only a flip's own screen gets | |
| 631 | + | // a fresh Seq for its animation, so another output keeps its Seq while it draws the same. | |
| 642 | 632 | func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |
| 643 | 633 | if c.inputEpochChanged() { | |
| 644 | 634 | return errSecurityChanged | |
| @@ -685,44 +675,18 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 685 | 675 | return err | |
| 686 | 676 | } | |
| 687 | 677 | 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 | |
| 678 | + | c.advanceFlash(only) | |
| 700 | 679 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 701 | 680 | // Before the first output (and after the last is unplugged) the | |
| 702 | 681 | // placeholder's scene has no output name: no renderer draws it. | |
| 703 | 682 | for i, sc := range c.screens { | |
| 704 | - | scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse) | |
| 683 | + | scene, err := c.sceneFor(ctx, i, sc, only, capture) | |
| 705 | 684 | if err != nil { | |
| 706 | 685 | return err | |
| 707 | 686 | } | |
| 708 | - | drawable = drawable || shown | |
| 709 | 687 | scenes = append(scenes, scene) | |
| 710 | 688 | } | |
| 711 | 689 | 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 | 690 | for i, sc := range c.screens { | |
| 727 | 691 | sc.last = scenes[i] | |
| 728 | 692 | } | |
| @@ -776,11 +740,11 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||
| 776 | 740 | return c.slidFrame(ctx, false, only) | |
| 777 | 741 | } | |
| 778 | 742 | ||
| 779 | - | // animatingOther reports whether a spring or the pulse runs on a screen | |
| 743 | + | // animatingOther reports whether a spring runs on a screen | |
| 780 | 744 | // other than only. | |
| 781 | 745 | func (c *Core) animatingOther(only *screen) bool { | |
| 782 | 746 | for _, sc := range c.screens { | |
| 783 | - | if sc != only && (sc.springing() || c.pulsing(sc)) { | |
| 747 | + | if sc != only && (sc.springing() || c.flashing(sc)) { | |
| 784 | 748 | return true | |
| 785 | 749 | } | |
| 786 | 750 | } | |
| @@ -1128,7 +1092,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1128 | 1092 | c.syncSecurity() | |
| 1129 | 1093 | defer c.stopFrame() | |
| 1130 | 1094 | defer c.stopCaptureTimer() | |
| 1131 | - | defer c.stopPulseTimer() | |
| 1095 | + | defer c.stopFlashTimer() | |
| 1132 | 1096 | defer c.cancelSwitcher() | |
| 1133 | 1097 | defer c.stopRepeat() | |
| 1134 | 1098 | // Startup commands run once per session. Full launcher queues retain a | |
| @@ -1198,11 +1162,11 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1198 | 1162 | return nil | |
| 1199 | 1163 | } | |
| 1200 | 1164 | continue | |
| 1201 | - | case <-c.pulse.timerC: | |
| 1165 | + | case <-c.flash.timerC: | |
| 1202 | 1166 | if c.securityCheckpoint(ctx) != nil { | |
| 1203 | 1167 | return nil | |
| 1204 | 1168 | } | |
| 1205 | - | if c.pulseTick() && c.publish(ctx) != nil { | |
| 1169 | + | if c.flashTick() && c.publish(ctx) != nil { | |
| 1206 | 1170 | return nil | |
| 1207 | 1171 | } | |
| 1208 | 1172 | continue | |
| @@ -155,9 +155,8 @@ type Core struct { | |||||
| 155 | motionTime time.Duration | 155 | motionTime time.Duration | ||
| 156 | pointerAt [2]float64 | 156 | 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 captureState | 158 | + | capt captureState |
| 159 | - | // pulse marks a window that just got the focus (pulse.go). | 159 | + | flash focusFlash |
| 160 | - | pulse focusPulse | |||
| 161 | // switcher is the column switcher in progress: the screen it is on and | 160 | // switcher is the column switcher in progress: the screen it is on and | ||
| 162 | // the timer that shows its cards (switcher.go). | 161 | // the timer that shows its cards (switcher.go). | ||
| 163 | switcher struct { | 162 | switcher struct { | ||
| @@ -532,14 +531,6 @@ func (v viewport) area(p Placement) Rect { | |||||
| 532 | return v.usable | 531 | return v.usable | ||
| 533 | } | 532 | } | ||
| 534 | 533 | ||||
| 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 its | 534 | // shows reports whether the user sees p: not hidden, and overlapping its | ||
| 544 | // area. | 535 | // area. | ||
| 545 | func (v viewport) shows(p Placement) bool { | 536 | func (v viewport) shows(p Placement) bool { | ||
| @@ -636,9 +627,8 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||||
| 636 | func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) } | 627 | func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) } | ||
| 637 | 628 | ||||
| 638 | // publishFrame publishes the scenes. only is the screen whose page flip | 629 | // 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 | 630 | + | // 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 for | 631 | + | // 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 { | 632 | func (c *Core) publishFrame(ctx context.Context, only *screen) error { | ||
| 643 | if c.inputEpochChanged() { | 633 | if c.inputEpochChanged() { | ||
| 644 | return errSecurityChanged | 634 | return errSecurityChanged | ||
| @@ -685,44 +675,18 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 685 | return err | 675 | return err | ||
| 686 | } | 676 | } | ||
| 687 | focus := c.keyboardFocus() | 677 | focus := c.keyboardFocus() | ||
| 688 | - | window, _ := c.cur().mon.Focused() | 678 | + | c.advanceFlash(only) |
| 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)) | 679 | scenes := make([]ports.Scene, 0, len(c.screens)) | ||
| 701 | // Before the first output (and after the last is unplugged) the | 680 | // Before the first output (and after the last is unplugged) the | ||
| 702 | // placeholder's scene has no output name: no renderer draws it. | 681 | // placeholder's scene has no output name: no renderer draws it. | ||
| 703 | for i, sc := range c.screens { | 682 | for i, sc := range c.screens { | ||
| 704 | - | scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse) | 683 | + | scene, err := c.sceneFor(ctx, i, sc, only, capture) |
| 705 | if err != nil { | 684 | if err != nil { | ||
| 706 | return err | 685 | return err | ||
| 707 | } | 686 | } | ||
| 708 | - | drawable = drawable || shown | |||
| 709 | scenes = append(scenes, scene) | 687 | scenes = append(scenes, scene) | ||
| 710 | } | 688 | } | ||
| 711 | c.configures.prune() | 689 | 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 { | 690 | for i, sc := range c.screens { | ||
| 727 | sc.last = scenes[i] | 691 | sc.last = scenes[i] | ||
| 728 | } | 692 | } | ||
| @@ -776,11 +740,11 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||||
| 776 | return c.slidFrame(ctx, false, only) | 740 | return c.slidFrame(ctx, false, only) | ||
| 777 | } | 741 | } | ||
| 778 | 742 | ||||
| 779 | - | // animatingOther reports whether a spring or the pulse runs on a screen | 743 | + | // animatingOther reports whether a spring runs on a screen |
| 780 | // other than only. | 744 | // other than only. | ||
| 781 | func (c *Core) animatingOther(only *screen) bool { | 745 | func (c *Core) animatingOther(only *screen) bool { | ||
| 782 | for _, sc := range c.screens { | 746 | for _, sc := range c.screens { | ||
| 783 | - | if sc != only && (sc.springing() || c.pulsing(sc)) { | 747 | + | if sc != only && (sc.springing() || c.flashing(sc)) { |
| 784 | return true | 748 | return true | ||
| 785 | } | 749 | } | ||
| 786 | } | 750 | } | ||
| @@ -1128,7 +1092,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1128 | c.syncSecurity() | 1092 | c.syncSecurity() | ||
| 1129 | defer c.stopFrame() | 1093 | defer c.stopFrame() | ||
| 1130 | defer c.stopCaptureTimer() | 1094 | defer c.stopCaptureTimer() | ||
| 1131 | - | defer c.stopPulseTimer() | 1095 | + | defer c.stopFlashTimer() |
| 1132 | defer c.cancelSwitcher() | 1096 | defer c.cancelSwitcher() | ||
| 1133 | defer c.stopRepeat() | 1097 | defer c.stopRepeat() | ||
| 1134 | // Startup commands run once per session. Full launcher queues retain a | 1098 | // Startup commands run once per session. Full launcher queues retain a | ||
| @@ -1198,11 +1162,11 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1198 | return nil | 1162 | return nil | ||
| 1199 | } | 1163 | } | ||
| 1200 | continue | 1164 | continue | ||
| 1201 | - | case <-c.pulse.timerC: | 1165 | + | case <-c.flash.timerC: |
| 1202 | if c.securityCheckpoint(ctx) != nil { | 1166 | if c.securityCheckpoint(ctx) != nil { | ||
| 1203 | return nil | 1167 | return nil | ||
| 1204 | } | 1168 | } | ||
| 1205 | - | if c.pulseTick() && c.publish(ctx) != nil { | 1169 | + | if c.flashTick() && c.publish(ctx) != nil { |
| 1206 | return nil | 1170 | return nil | ||
| 1207 | } | 1171 | } | ||
| 1208 | continue | 1172 | continue | ||
M internal/core/core_test.go +0 -2
| @@ -973,7 +973,6 @@ func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored) | |||
| 973 | 973 | func fullscreenAtMapIgnored(t *testing.T, animated bool) { | |
| 974 | 974 | cfg := scrollDefaults() | |
| 975 | 975 | cfg.Layout.Overflow = "fixed" | |
| 976 | - | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 977 | 976 | client := make(chan ports.ClientEvent, 8) | |
| 978 | 977 | output := make(chan ports.OutputEvent, 8) | |
| 979 | 978 | commands := make(chan ports.ClientCommand, 64) | |
| @@ -1063,7 +1062,6 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscre | |||
| 1063 | 1062 | ||
| 1064 | 1063 | func externalFullscreenAtMapApplies(t *testing.T, animated bool) { | |
| 1065 | 1064 | cfg := scrollDefaults() | |
| 1066 | - | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 1067 | 1065 | client := make(chan ports.ClientEvent, 8) | |
| 1068 | 1066 | output := make(chan ports.OutputEvent, 8) | |
| 1069 | 1067 | 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) | ||
M internal/core/drag.go +1 -0
| @@ -243,6 +243,7 @@ func (c *Core) endDrag(ctx context.Context) error { | |||
| 243 | 243 | now := c.now() | |
| 244 | 244 | shots := c.snapshot(now) | |
| 245 | 245 | c.drop(d.id, t) | |
| 246 | + | c.requestFlash(flashWindow, d.id) | |
| 246 | 247 | c.transition(shots, now) | |
| 247 | 248 | // Slots and guests follow the windows, as after a bind. | |
| 248 | 249 | if err := c.workspaceVisible(ctx, false); err != nil { | |
| @@ -243,6 +243,7 @@ func (c *Core) endDrag(ctx context.Context) error { | |||||
| 243 | now := c.now() | 243 | now := c.now() | ||
| 244 | shots := c.snapshot(now) | 244 | shots := c.snapshot(now) | ||
| 245 | c.drop(d.id, t) | 245 | c.drop(d.id, t) | ||
| 246 | + | c.requestFlash(flashWindow, d.id) | |||
| 246 | c.transition(shots, now) | 247 | c.transition(shots, now) | ||
| 247 | // Slots and guests follow the windows, as after a bind. | 248 | // Slots and guests follow the windows, as after a bind. | ||
| 248 | if err := c.workspaceVisible(ctx, false); err != nil { | 249 | if err := c.workspaceVisible(ctx, false); err != nil { | ||
A internal/core/flash.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type flashLevel uint8 | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | flashWindow flashLevel = iota | |
| 14 | + | flashColumn | |
| 15 | + | flashMonitor | |
| 16 | + | flashSettle = 150 * time.Millisecond | |
| 17 | + | flashRecheck = 50 * time.Millisecond | |
| 18 | + | flashCooldown = time.Second | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type flashTarget struct { | |
| 22 | + | level flashLevel | |
| 23 | + | window WindowID | |
| 24 | + | output string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type focusFlash struct { | |
| 28 | + | pending *flashTarget | |
| 29 | + | since time.Time | |
| 30 | + | timerC <-chan time.Time | |
| 31 | + | timerStop func() bool | |
| 32 | + | running *flashTarget | |
| 33 | + | rect Rect | |
| 34 | + | start time.Time | |
| 35 | + | value float64 // Retained between this output's own flips. | |
| 36 | + | lastStarts map[flashTarget]time.Time | |
| 37 | + | layout []Placement // Owner scratch, never shared with a published scene. | |
| 38 | + | published *ports.FocusFlash // Immutable last sample, reused when unchanged. | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | |
| 42 | + | ||
| 43 | + | func (c *Core) requestFlash(level flashLevel, window WindowID) { | |
| 44 | + | if !c.flashOn() { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | c.stopFlash() | |
| 48 | + | f := &c.flash | |
| 49 | + | f.pending = &flashTarget{level: level, window: window, output: c.cur().name()} | |
| 50 | + | f.since = c.now() | |
| 51 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (c *Core) flashTick() bool { | |
| 55 | + | f := &c.flash | |
| 56 | + | f.timerC, f.timerStop = nil, nil | |
| 57 | + | if f.pending == nil { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | target := *f.pending | |
| 61 | + | f.pending = nil | |
| 62 | + | if !c.flashOn() { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | i := c.screenIndex(target.output) | |
| 66 | + | if i < 0 { | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | sc := c.screens[i] | |
| 70 | + | if sc.mon.ov.open { | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | now := c.now() | |
| 74 | + | if target.level != flashMonitor { | |
| 75 | + | owner, _ := c.screenOf(target.window) | |
| 76 | + | record := c.windows.lookup(target.window) | |
| 77 | + | if owner != sc || record.client.ID == 0 { | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | wait := flashSettle - now.Sub(f.since) | |
| 82 | + | if wait > 0 || !sc.settled() { | |
| 83 | + | if wait <= 0 { | |
| 84 | + | wait = flashRecheck | |
| 85 | + | } | |
| 86 | + | f.pending = &target | |
| 87 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, wait) | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | layout := sc.mon.Layout() | |
| 91 | + | var rect Rect | |
| 92 | + | if target.level == flashMonitor { | |
| 93 | + | o := sc.mon.Output() | |
| 94 | + | rect = Rect{W: o.W, H: o.H} | |
| 95 | + | } else { | |
| 96 | + | at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 }) | |
| 97 | + | if at < 0 || layout[at].Fullscreen { | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | rect = layout[at].Rect | |
| 101 | + | if target.level == flashColumn { | |
| 102 | + | w := sc.mon.Current() | |
| 103 | + | col := w.columnOf(target.window) | |
| 104 | + | if col < 0 { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | for _, p := range layout { | |
| 108 | + | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | rect = union(rect, p.Rect) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | if f.lastStarts == nil { | |
| 119 | + | f.lastStarts = make(map[flashTarget]time.Time) | |
| 120 | + | } | |
| 121 | + | for key, at := range f.lastStarts { | |
| 122 | + | if now.Sub(at) >= flashCooldown { | |
| 123 | + | delete(f.lastStarts, key) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | f.lastStarts[target] = now | |
| 127 | + | f.running, f.rect, f.start = &target, rect, now | |
| 128 | + | f.value = c.cfg.Focus.FlashOpacity | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (c *Core) advanceFlash(only *screen) { | |
| 133 | + | f := &c.flash | |
| 134 | + | if f.running == nil { | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | if !c.flashOn() || c.screenIndex(f.running.output) < 0 { | |
| 138 | + | c.stopFlash() | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | sc := c.screens[c.screenIndex(f.running.output)] | |
| 142 | + | if !c.flashVisible(*f.running, sc) { | |
| 143 | + | c.stopFlash() | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | if only != nil && only.name() != f.running.output { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | d := c.cfg.Focus.FlashDuration | |
| 150 | + | elapsed := c.now().Sub(f.start) | |
| 151 | + | if d <= 0 || elapsed >= d { | |
| 152 | + | f.running = nil | |
| 153 | + | f.value = 0 | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d)) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // flashVisible checks the current layout, not leaving placements retained for | |
| 160 | + | // transitions: an old border must not outlive its target's visibility. | |
| 161 | + | func (c *Core) flashVisible(target flashTarget, sc *screen) bool { | |
| 162 | + | if sc.mon.ov.open { | |
| 163 | + | return false | |
| 164 | + | } | |
| 165 | + | if target.level == flashMonitor { | |
| 166 | + | return true | |
| 167 | + | } | |
| 168 | + | owner, _ := c.screenOf(target.window) | |
| 169 | + | if owner != sc || c.windows.lookup(target.window).client.ID == 0 { | |
| 170 | + | return false | |
| 171 | + | } | |
| 172 | + | view := sc.mon.viewport() | |
| 173 | + | c.flash.layout = sc.mon.layoutInto(c.flash.layout) | |
| 174 | + | for _, p := range c.flash.layout { | |
| 175 | + | if p.ID == target.window { | |
| 176 | + | return !p.Fullscreen && p.Preview == 0 && view.shows(p) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |
| 183 | + | f := &c.flash | |
| 184 | + | if !c.flashing(sc) { | |
| 185 | + | return nil | |
| 186 | + | } | |
| 187 | + | color := c.cfg.Focus.FlashColor | |
| 188 | + | if color == "" { | |
| 189 | + | color = c.cfg.Border.Active | |
| 190 | + | } | |
| 191 | + | sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | |
| 192 | + | if f.published == nil || *f.published != sample { | |
| 193 | + | f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha} | |
| 194 | + | } | |
| 195 | + | return f.published | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (c *Core) flashing(sc *screen) bool { | |
| 199 | + | return c.flash.running != nil && c.flash.running.output == sc.name() | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (c *Core) stopFlashTimer() { | |
| 203 | + | if c.flash.timerStop != nil { | |
| 204 | + | c.flash.timerStop() | |
| 205 | + | } | |
| 206 | + | c.flash.timerC, c.flash.timerStop = nil, nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (c *Core) stopFlash() { | |
| 210 | + | c.stopFlashTimer() | |
| 211 | + | c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0 | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (m *Monitor) settled() bool { | |
| 215 | + | if m.switchView.busy() { | |
| 216 | + | return false | |
| 217 | + | } | |
| 218 | + | for w := range m.all() { | |
| 219 | + | if w.view.busy() || w.stashView.busy() { | |
| 220 | + | return false | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | return true | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 } | |
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type flashLevel uint8 | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | flashWindow flashLevel = iota | |||
| 14 | + | flashColumn | |||
| 15 | + | flashMonitor | |||
| 16 | + | flashSettle = 150 * time.Millisecond | |||
| 17 | + | flashRecheck = 50 * time.Millisecond | |||
| 18 | + | flashCooldown = time.Second | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type flashTarget struct { | |||
| 22 | + | level flashLevel | |||
| 23 | + | window WindowID | |||
| 24 | + | output string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type focusFlash struct { | |||
| 28 | + | pending *flashTarget | |||
| 29 | + | since time.Time | |||
| 30 | + | timerC <-chan time.Time | |||
| 31 | + | timerStop func() bool | |||
| 32 | + | running *flashTarget | |||
| 33 | + | rect Rect | |||
| 34 | + | start time.Time | |||
| 35 | + | value float64 // Retained between this output's own flips. | |||
| 36 | + | lastStarts map[flashTarget]time.Time | |||
| 37 | + | layout []Placement // Owner scratch, never shared with a published scene. | |||
| 38 | + | published *ports.FocusFlash // Immutable last sample, reused when unchanged. | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | |||
| 42 | + | ||||
| 43 | + | func (c *Core) requestFlash(level flashLevel, window WindowID) { | |||
| 44 | + | if !c.flashOn() { | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | c.stopFlash() | |||
| 48 | + | f := &c.flash | |||
| 49 | + | f.pending = &flashTarget{level: level, window: window, output: c.cur().name()} | |||
| 50 | + | f.since = c.now() | |||
| 51 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (c *Core) flashTick() bool { | |||
| 55 | + | f := &c.flash | |||
| 56 | + | f.timerC, f.timerStop = nil, nil | |||
| 57 | + | if f.pending == nil { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | target := *f.pending | |||
| 61 | + | f.pending = nil | |||
| 62 | + | if !c.flashOn() { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | i := c.screenIndex(target.output) | |||
| 66 | + | if i < 0 { | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | sc := c.screens[i] | |||
| 70 | + | if sc.mon.ov.open { | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | now := c.now() | |||
| 74 | + | if target.level != flashMonitor { | |||
| 75 | + | owner, _ := c.screenOf(target.window) | |||
| 76 | + | record := c.windows.lookup(target.window) | |||
| 77 | + | if owner != sc || record.client.ID == 0 { | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | wait := flashSettle - now.Sub(f.since) | |||
| 82 | + | if wait > 0 || !sc.settled() { | |||
| 83 | + | if wait <= 0 { | |||
| 84 | + | wait = flashRecheck | |||
| 85 | + | } | |||
| 86 | + | f.pending = &target | |||
| 87 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, wait) | |||
| 88 | + | return false | |||
| 89 | + | } | |||
| 90 | + | layout := sc.mon.Layout() | |||
| 91 | + | var rect Rect | |||
| 92 | + | if target.level == flashMonitor { | |||
| 93 | + | o := sc.mon.Output() | |||
| 94 | + | rect = Rect{W: o.W, H: o.H} | |||
| 95 | + | } else { | |||
| 96 | + | at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 }) | |||
| 97 | + | if at < 0 || layout[at].Fullscreen { | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | rect = layout[at].Rect | |||
| 101 | + | if target.level == flashColumn { | |||
| 102 | + | w := sc.mon.Current() | |||
| 103 | + | col := w.columnOf(target.window) | |||
| 104 | + | if col < 0 { | |||
| 105 | + | return false | |||
| 106 | + | } | |||
| 107 | + | for _, p := range layout { | |||
| 108 | + | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | rect = union(rect, p.Rect) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown { | |||
| 116 | + | return false | |||
| 117 | + | } | |||
| 118 | + | if f.lastStarts == nil { | |||
| 119 | + | f.lastStarts = make(map[flashTarget]time.Time) | |||
| 120 | + | } | |||
| 121 | + | for key, at := range f.lastStarts { | |||
| 122 | + | if now.Sub(at) >= flashCooldown { | |||
| 123 | + | delete(f.lastStarts, key) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | f.lastStarts[target] = now | |||
| 127 | + | f.running, f.rect, f.start = &target, rect, now | |||
| 128 | + | f.value = c.cfg.Focus.FlashOpacity | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (c *Core) advanceFlash(only *screen) { | |||
| 133 | + | f := &c.flash | |||
| 134 | + | if f.running == nil { | |||
| 135 | + | return | |||
| 136 | + | } | |||
| 137 | + | if !c.flashOn() || c.screenIndex(f.running.output) < 0 { | |||
| 138 | + | c.stopFlash() | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | sc := c.screens[c.screenIndex(f.running.output)] | |||
| 142 | + | if !c.flashVisible(*f.running, sc) { | |||
| 143 | + | c.stopFlash() | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | if only != nil && only.name() != f.running.output { | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | d := c.cfg.Focus.FlashDuration | |||
| 150 | + | elapsed := c.now().Sub(f.start) | |||
| 151 | + | if d <= 0 || elapsed >= d { | |||
| 152 | + | f.running = nil | |||
| 153 | + | f.value = 0 | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d)) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // flashVisible checks the current layout, not leaving placements retained for | |||
| 160 | + | // transitions: an old border must not outlive its target's visibility. | |||
| 161 | + | func (c *Core) flashVisible(target flashTarget, sc *screen) bool { | |||
| 162 | + | if sc.mon.ov.open { | |||
| 163 | + | return false | |||
| 164 | + | } | |||
| 165 | + | if target.level == flashMonitor { | |||
| 166 | + | return true | |||
| 167 | + | } | |||
| 168 | + | owner, _ := c.screenOf(target.window) | |||
| 169 | + | if owner != sc || c.windows.lookup(target.window).client.ID == 0 { | |||
| 170 | + | return false | |||
| 171 | + | } | |||
| 172 | + | view := sc.mon.viewport() | |||
| 173 | + | c.flash.layout = sc.mon.layoutInto(c.flash.layout) | |||
| 174 | + | for _, p := range c.flash.layout { | |||
| 175 | + | if p.ID == target.window { | |||
| 176 | + | return !p.Fullscreen && p.Preview == 0 && view.shows(p) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |||
| 183 | + | f := &c.flash | |||
| 184 | + | if !c.flashing(sc) { | |||
| 185 | + | return nil | |||
| 186 | + | } | |||
| 187 | + | color := c.cfg.Focus.FlashColor | |||
| 188 | + | if color == "" { | |||
| 189 | + | color = c.cfg.Border.Active | |||
| 190 | + | } | |||
| 191 | + | sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | |||
| 192 | + | if f.published == nil || *f.published != sample { | |||
| 193 | + | f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha} | |||
| 194 | + | } | |||
| 195 | + | return f.published | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (c *Core) flashing(sc *screen) bool { | |||
| 199 | + | return c.flash.running != nil && c.flash.running.output == sc.name() | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (c *Core) stopFlashTimer() { | |||
| 203 | + | if c.flash.timerStop != nil { | |||
| 204 | + | c.flash.timerStop() | |||
| 205 | + | } | |||
| 206 | + | c.flash.timerC, c.flash.timerStop = nil, nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (c *Core) stopFlash() { | |||
| 210 | + | c.stopFlashTimer() | |||
| 211 | + | c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0 | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (m *Monitor) settled() bool { | |||
| 215 | + | if m.switchView.busy() { | |||
| 216 | + | return false | |||
| 217 | + | } | |||
| 218 | + | for w := range m.all() { | |||
| 219 | + | if w.view.busy() || w.stashView.busy() { | |||
| 220 | + | return false | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | return true | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 } | |||
A internal/core/flash_test.go +600 -0
| @@ -0,0 +1,600 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func flashCore(t *testing.T) (*Core, *indicatorClock) { | |
| 13 | + | t.Helper() | |
| 14 | + | c, clock := indicatorCore(t) | |
| 15 | + | c.cfg.Animations.On = true | |
| 16 | + | c.cfg.Focus.Flash = true | |
| 17 | + | c.cfg.Focus.FlashWidth = 3 | |
| 18 | + | c.cfg.Focus.FlashOpacity = 0.8 | |
| 19 | + | c.cfg.Focus.FlashDuration = 400 * time.Millisecond | |
| 20 | + | c.cfg.Border.Active = "#abcdef" | |
| 21 | + | for id := WindowID(1); id <= 3; id++ { | |
| 22 | + | c.windows.mapped(ports.WindowMapped{ID: id}, clock.now.Add(-time.Minute)) | |
| 23 | + | c.cur().mon.AddWindow(id) | |
| 24 | + | } | |
| 25 | + | return c, clock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func startFlash(t *testing.T, c *Core, clock *indicatorClock, level flashLevel, id WindowID) { | |
| 29 | + | t.Helper() | |
| 30 | + | c.requestFlash(level, id) | |
| 31 | + | clock.now = clock.now.Add(flashSettle) | |
| 32 | + | if !c.flashTick() { | |
| 33 | + | t.Fatal("flash did not start") | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestFlashFadeAndOutputFrames(t *testing.T) { | |
| 38 | + | c, clock := flashCore(t) | |
| 39 | + | a := c.cur() | |
| 40 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |
| 41 | + | b := c.screens[1] | |
| 42 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 43 | + | first := stepScenes(t, c, a) | |
| 44 | + | f := first["A"].Flash | |
| 45 | + | if f == nil || f.Alpha != 0.8 || f.Color != c.cfg.Border.Active || f.Width != 3 { | |
| 46 | + | t.Fatalf("flash %+v", f) | |
| 47 | + | } | |
| 48 | + | if first["B"].Flash != nil || !first["A"].Animating || !c.animating() { | |
| 49 | + | t.Fatal("output/animation routing") | |
| 50 | + | } | |
| 51 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |
| 52 | + | other := stepScenes(t, c, b) | |
| 53 | + | if other["A"].Seq != first["A"].Seq || *other["A"].Flash != *f { | |
| 54 | + | t.Fatal("other output advanced flash") | |
| 55 | + | } | |
| 56 | + | half := stepScenes(t, c, a) | |
| 57 | + | if half["A"].Flash.Alpha != 0.4 { | |
| 58 | + | t.Fatalf("half alpha %v", half["A"].Flash.Alpha) | |
| 59 | + | } | |
| 60 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |
| 61 | + | end := stepScenes(t, c, a) | |
| 62 | + | if end["A"].Flash != nil || end["A"].Animating || c.animating() { | |
| 63 | + | t.Fatal("flash did not stop frames") | |
| 64 | + | } | |
| 65 | + | if end["A"].Seq == half["A"].Seq { | |
| 66 | + | t.Fatal("end retained animating sequence") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestFlashDebounceCooldownAndReplacement(t *testing.T) { | |
| 71 | + | c, clock := flashCore(t) | |
| 72 | + | c.requestFlash(flashWindow, 1) | |
| 73 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |
| 74 | + | c.requestFlash(flashWindow, 2) | |
| 75 | + | clock.now = clock.now.Add(50 * time.Millisecond) | |
| 76 | + | if c.flashTick() || c.flash.pending == nil { | |
| 77 | + | t.Fatal("early stale timer started flash") | |
| 78 | + | } | |
| 79 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |
| 80 | + | if !c.flashTick() || c.flash.running.window != 2 { | |
| 81 | + | t.Fatal("last request did not win") | |
| 82 | + | } | |
| 83 | + | c.requestFlash(flashWindow, 2) | |
| 84 | + | if c.flash.running != nil { | |
| 85 | + | t.Fatal("request did not replace running flash") | |
| 86 | + | } | |
| 87 | + | clock.now = clock.now.Add(flashSettle) | |
| 88 | + | if c.flashTick() { | |
| 89 | + | t.Fatal("cooldown ignored") | |
| 90 | + | } | |
| 91 | + | clock.now = clock.now.Add(time.Second) | |
| 92 | + | startFlash(t, c, clock, flashWindow, 2) | |
| 93 | + | c.requestFlash(flashColumn, 2) | |
| 94 | + | clock.now = clock.now.Add(flashSettle) | |
| 95 | + | if !c.flashTick() || c.flash.running.level != flashColumn { | |
| 96 | + | t.Fatal("level not part of cooldown key") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestFlashSettlesAndResolvesFinalRect(t *testing.T) { | |
| 101 | + | for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} { | |
| 102 | + | t.Run(string(rune('0'+level)), func(t *testing.T) { | |
| 103 | + | c, clock := flashCore(t) | |
| 104 | + | w := c.cur().mon.Current() | |
| 105 | + | c.requestFlash(level, 3) | |
| 106 | + | w.view.off = 20 | |
| 107 | + | clock.now = clock.now.Add(flashSettle) | |
| 108 | + | if c.flashTick() || c.flash.timerC == nil { | |
| 109 | + | t.Fatal("did not wait for layout") | |
| 110 | + | } | |
| 111 | + | w.view.stop() | |
| 112 | + | w.MoveColumn(-1) | |
| 113 | + | clock.now = clock.now.Add(flashRecheck) | |
| 114 | + | if !c.flashTick() { | |
| 115 | + | t.Fatal("did not start after settling") | |
| 116 | + | } | |
| 117 | + | want := Rect{W: 300, H: 200} | |
| 118 | + | if level != flashMonitor { | |
| 119 | + | for _, p := range c.cur().mon.Layout() { | |
| 120 | + | if p.ID == 3 { | |
| 121 | + | want = p.Rect | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | if c.flash.rect != want { | |
| 126 | + | t.Fatalf("rect %+v want %+v", c.flash.rect, want) | |
| 127 | + | } | |
| 128 | + | }) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestFlashSkipsIneligibleTargets(t *testing.T) { | |
| 133 | + | for _, test := range []struct { | |
| 134 | + | name string | |
| 135 | + | change func(*Core, *indicatorClock) | |
| 136 | + | }{ | |
| 137 | + | {"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }}, | |
| 138 | + | {"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }}, | |
| 139 | + | {"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }}, | |
| 140 | + | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | |
| 141 | + | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | |
| 142 | + | {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }}, | |
| 143 | + | } { | |
| 144 | + | t.Run(test.name, func(t *testing.T) { | |
| 145 | + | c, clock := flashCore(t) | |
| 146 | + | test.change(c, clock) | |
| 147 | + | c.requestFlash(flashWindow, 3) | |
| 148 | + | clock.now = clock.now.Add(flashSettle) | |
| 149 | + | if c.flashTick() || c.flash.running != nil { | |
| 150 | + | t.Fatal("ineligible flash") | |
| 151 | + | } | |
| 152 | + | }) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestFlashStopAndColor(t *testing.T) { | |
| 157 | + | c, clock := flashCore(t) | |
| 158 | + | c.cfg.Focus.FlashColor = "#123456" | |
| 159 | + | c.cfg.Animations.Slowdown = 10 | |
| 160 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 161 | + | if s := indicatorScene(t, c); s.Flash.Color != "#123456" { | |
| 162 | + | t.Fatal("configured color ignored") | |
| 163 | + | } | |
| 164 | + | clock.now = clock.now.Add(c.cfg.Focus.FlashDuration) | |
| 165 | + | if indicatorScene(t, c).Flash != nil { | |
| 166 | + | t.Fatal("duration scaled by slowdown") | |
| 167 | + | } | |
| 168 | + | c.requestFlash(flashWindow, 2) | |
| 169 | + | c.stopFlash() | |
| 170 | + | if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil { | |
| 171 | + | t.Fatal("stop retained state") | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestFlashActionTriggers(t *testing.T) { | |
| 176 | + | for _, test := range []struct { | |
| 177 | + | action Action | |
| 178 | + | level flashLevel | |
| 179 | + | setup func(*Core) | |
| 180 | + | }{ | |
| 181 | + | {ActionFocusColumnLeft, flashColumn, nil}, | |
| 182 | + | {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |
| 183 | + | {ActionMoveColumnLeft, flashColumn, nil}, | |
| 184 | + | {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |
| 185 | + | {ActionConsumeOrExpelLeft, flashWindow, nil}, | |
| 186 | + | {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |
| 187 | + | {ActionToggleFloating, flashWindow, nil}, | |
| 188 | + | {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |
| 189 | + | {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |
| 190 | + | {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |
| 191 | + | {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |
| 192 | + | {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }}, | |
| 193 | + | {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }}, | |
| 194 | + | {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }}, | |
| 195 | + | } { | |
| 196 | + | t.Run(string(test.action), func(t *testing.T) { | |
| 197 | + | c, clock := flashCore(t) | |
| 198 | + | if test.setup != nil { | |
| 199 | + | test.setup(c) | |
| 200 | + | } | |
| 201 | + | c.applyAction(test.action) | |
| 202 | + | if c.flash.pending == nil || c.flash.pending.level != test.level { | |
| 203 | + | t.Fatalf("pending %+v want level %v", c.flash.pending, test.level) | |
| 204 | + | } | |
| 205 | + | target := *c.flash.pending | |
| 206 | + | clock.now = clock.now.Add(flashSettle) | |
| 207 | + | if !c.flashTick() { | |
| 208 | + | t.Fatal("action flash did not start") | |
| 209 | + | } | |
| 210 | + | var want Rect | |
| 211 | + | for _, p := range c.cur().mon.Layout() { | |
| 212 | + | if p.ID == target.window { | |
| 213 | + | want = p.Rect | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | if c.flash.rect != want { | |
| 217 | + | t.Fatalf("action rect %+v want %+v", c.flash.rect, want) | |
| 218 | + | } | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestFlashExcludedActions(t *testing.T) { | |
| 224 | + | for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} { | |
| 225 | + | t.Run(string(action), func(t *testing.T) { | |
| 226 | + | c, _ := flashCore(t) | |
| 227 | + | c.applyAction(action) | |
| 228 | + | if c.flash.pending != nil { | |
| 229 | + | t.Fatalf("unexpected flash %+v", c.flash.pending) | |
| 230 | + | } | |
| 231 | + | }) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestFlashMonitorTriggers(t *testing.T) { | |
| 236 | + | for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} { | |
| 237 | + | t.Run(string(action), func(t *testing.T) { | |
| 238 | + | c, clock := flashCore(t) | |
| 239 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |
| 240 | + | a, b := c.screens[0], c.screens[1] | |
| 241 | + | a.x, a.y = 0, 0 | |
| 242 | + | b.x, b.y = 300, 0 | |
| 243 | + | switch monitorDirection(action) { | |
| 244 | + | case dirLeft: | |
| 245 | + | b.x = -400 | |
| 246 | + | case dirUp: | |
| 247 | + | b.x, b.y = 0, -300 | |
| 248 | + | case dirDown: | |
| 249 | + | b.x, b.y = 0, 200 | |
| 250 | + | } | |
| 251 | + | c.applyAction(action) | |
| 252 | + | if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" { | |
| 253 | + | t.Fatalf("destination request %+v", c.flash.pending) | |
| 254 | + | } | |
| 255 | + | clock.now = clock.now.Add(flashSettle) | |
| 256 | + | if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) { | |
| 257 | + | t.Fatalf("monitor rect %+v", c.flash.rect) | |
| 258 | + | } | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestFlashColumnUnion(t *testing.T) { | |
| 264 | + | c, clock := flashCore(t) | |
| 265 | + | w := c.cur().mon.Current() | |
| 266 | + | w.ConsumeOrExpel(-1) | |
| 267 | + | startFlash(t, c, clock, flashColumn, 3) | |
| 268 | + | col := w.Columns[w.columnOf(3)] | |
| 269 | + | var area int | |
| 270 | + | for _, p := range c.cur().mon.Layout() { | |
| 271 | + | for _, id := range col.Windows { | |
| 272 | + | if id == p.ID { | |
| 273 | + | area += p.Rect.W * p.Rect.H | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | if c.flash.rect.W*c.flash.rect.H != area { | |
| 278 | + | t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func TestFlashGestureLanding(t *testing.T) { | |
| 283 | + | c, _ := flashCore(t) | |
| 284 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 285 | + | c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond}) | |
| 286 | + | c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond}) | |
| 287 | + | if c.flash.pending == nil || c.flash.pending.level != flashColumn { | |
| 288 | + | t.Fatalf("landing request %+v", c.flash.pending) | |
| 289 | + | } | |
| 290 | + | c.stopFlash() | |
| 291 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond}) | |
| 292 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond}) | |
| 293 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond}) | |
| 294 | + | if c.flash.pending != nil { | |
| 295 | + | t.Fatal("cancelled gesture flashed") | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func TestFlashLockClearsPendingAndRunning(t *testing.T) { | |
| 300 | + | for _, running := range []bool{false, true} { | |
| 301 | + | c, clock := flashCore(t) | |
| 302 | + | state := ports.SecurityState{} | |
| 303 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 304 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 305 | + | c.opts.Security = gate | |
| 306 | + | if running { | |
| 307 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 308 | + | } else { | |
| 309 | + | c.requestFlash(flashWindow, 3) | |
| 310 | + | } | |
| 311 | + | state = ports.SecurityState{Generation: 1, Protected: true} | |
| 312 | + | if !c.syncSecurity() { | |
| 313 | + | t.Fatal("security did not change") | |
| 314 | + | } | |
| 315 | + | if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil { | |
| 316 | + | t.Fatal("lock retained flash") | |
| 317 | + | } | |
| 318 | + | state = ports.SecurityState{Generation: 2} | |
| 319 | + | c.syncSecurity() | |
| 320 | + | if indicatorScene(t, c).Flash != nil || c.flash.pending != nil { | |
| 321 | + | t.Fatal("unlock requested flash") | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func TestFlashOverviewClickAndKeyboard(t *testing.T) { | |
| 327 | + | for _, click := range []bool{true, false} { | |
| 328 | + | c, _ := flashCore(t) | |
| 329 | + | c.cur().mon.ToggleOverview() | |
| 330 | + | scene := indicatorScene(t, c) | |
| 331 | + | if click { | |
| 332 | + | var rect Rect | |
| 333 | + | for _, p := range scene.Windows { | |
| 334 | + | if p.ID == 2 { | |
| 335 | + | rect = p.Rect | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |
| 339 | + | picked, err := c.overviewClick(context.Background()) | |
| 340 | + | if err != nil || !picked { | |
| 341 | + | t.Fatalf("click %v %v", picked, err) | |
| 342 | + | } | |
| 343 | + | } else { | |
| 344 | + | if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil { | |
| 345 | + | t.Fatal(err) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow { | |
| 349 | + | t.Fatalf("overview request %+v", c.flash.pending) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestFlashTileDrop(t *testing.T) { | |
| 355 | + | c, _ := flashCore(t) | |
| 356 | + | scene := indicatorScene(t, c) | |
| 357 | + | var rect Rect | |
| 358 | + | for _, p := range scene.Windows { | |
| 359 | + | if p.ID == 2 { | |
| 360 | + | rect = p.Rect | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |
| 364 | + | c.drag = &dragState{id: 3} | |
| 365 | + | if err := c.endDrag(context.Background()); err != nil { | |
| 366 | + | t.Fatal(err) | |
| 367 | + | } | |
| 368 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 { | |
| 369 | + | t.Fatalf("drop request %+v", c.flash.pending) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func TestFlashNoImplicitFocusTriggers(t *testing.T) { | |
| 374 | + | c, _ := flashCore(t) | |
| 375 | + | ctx := context.Background() | |
| 376 | + | indicatorScene(t, c) | |
| 377 | + | if c.flash.pending != nil { | |
| 378 | + | t.Fatal("startup publication flashed") | |
| 379 | + | } | |
| 380 | + | for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} { | |
| 381 | + | if _, stop := c.clientEvent(ctx, ev); stop { | |
| 382 | + | t.Fatalf("event %T stopped", ev) | |
| 383 | + | } | |
| 384 | + | if c.flash.pending != nil { | |
| 385 | + | t.Fatalf("event %T flashed", ev) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | indicatorScene(t, c) | |
| 389 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | |
| 390 | + | t.Fatal(err) | |
| 391 | + | } | |
| 392 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |
| 393 | + | t.Fatal(err) | |
| 394 | + | } | |
| 395 | + | if c.flash.pending != nil { | |
| 396 | + | t.Fatal("pointer focus flashed") | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func TestFlashCancelsStaleRunningTargets(t *testing.T) { | |
| 401 | + | for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} { | |
| 402 | + | for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} { | |
| 403 | + | t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) { | |
| 404 | + | c, clock := flashCore(t) | |
| 405 | + | a := c.cur() | |
| 406 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 407 | + | b := c.screens[1] | |
| 408 | + | startFlash(t, c, clock, level, 3) | |
| 409 | + | if indicatorScene(t, c).Flash == nil { | |
| 410 | + | t.Fatal("setup: no running flash") | |
| 411 | + | } | |
| 412 | + | switch change { | |
| 413 | + | case "overview": | |
| 414 | + | a.mon.ToggleOverview() | |
| 415 | + | case "output gone": | |
| 416 | + | c.screens = []*screen{b} | |
| 417 | + | c.focusScreen = 0 | |
| 418 | + | case "fullscreen": | |
| 419 | + | a.mon.ToggleFullscreen() | |
| 420 | + | case "unmapped": | |
| 421 | + | c.windows.drop(3) | |
| 422 | + | case "hidden": | |
| 423 | + | w := a.mon.Current() | |
| 424 | + | w.ToggleWindowStash() | |
| 425 | + | w.ToggleStashVisible() | |
| 426 | + | case "other output": | |
| 427 | + | a.mon.RemoveWindow(3) | |
| 428 | + | b.mon.AddWindow(3) | |
| 429 | + | case "workspace changed": | |
| 430 | + | a.mon.Focus(a.mon.Active + 1) | |
| 431 | + | } | |
| 432 | + | // Cancellation must not wait for the target output's next flip. | |
| 433 | + | scenes := stepScenes(t, c, b) | |
| 434 | + | shouldCancel := level != flashMonitor || change == "overview" || change == "output gone" | |
| 435 | + | if shouldCancel { | |
| 436 | + | if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() { | |
| 437 | + | t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating()) | |
| 438 | + | } | |
| 439 | + | } else if scenes["A"].Flash == nil || !c.animating() { | |
| 440 | + | t.Fatal("monitor flash cancelled for a window change") | |
| 441 | + | } | |
| 442 | + | }) | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func TestFlashCooldownRemembersEveryTarget(t *testing.T) { | |
| 448 | + | c, clock := flashCore(t) | |
| 449 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 450 | + | first := c.flash.start | |
| 451 | + | startFlash(t, c, clock, flashWindow, 2) | |
| 452 | + | c.requestFlash(flashWindow, 3) | |
| 453 | + | clock.now = clock.now.Add(flashSettle) | |
| 454 | + | if c.flashTick() || c.flash.running != nil { | |
| 455 | + | t.Fatal("A flashed again after B within cooldown") | |
| 456 | + | } | |
| 457 | + | clock.now = first.Add(flashCooldown) | |
| 458 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 459 | + | if c.flash.running.window != 3 { | |
| 460 | + | t.Fatal("A did not flash after cooldown") | |
| 461 | + | } | |
| 462 | + | // Inserting after all previous starts expire prunes old target keys. | |
| 463 | + | clock.now = clock.now.Add(flashCooldown) | |
| 464 | + | startFlash(t, c, clock, flashMonitor, 0) | |
| 465 | + | if len(c.flash.lastStarts) != 1 { | |
| 466 | + | t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // BenchmarkFlashSceneBuild includes revalidation and immutable scene creation. | |
| 471 | + | func BenchmarkFlashSceneBuild(b *testing.B) { | |
| 472 | + | var cfg ports.Config | |
| 473 | + | cfg.Layout.MaxColumns = 2 | |
| 474 | + | cfg.Keyboard.CmdKey = "super" | |
| 475 | + | cfg.Animations.On = true | |
| 476 | + | cfg.Focus.Flash = true | |
| 477 | + | cfg.Focus.FlashOpacity = 0.8 | |
| 478 | + | cfg.Focus.FlashDuration = time.Hour | |
| 479 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{}) | |
| 480 | + | if err != nil { | |
| 481 | + | b.Fatal(err) | |
| 482 | + | } | |
| 483 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600}) | |
| 484 | + | c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now()) | |
| 485 | + | c.cur().mon.AddWindow(1) | |
| 486 | + | c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"} | |
| 487 | + | c.flash.start = time.Now() | |
| 488 | + | c.flash.rect = Rect{W: 1000, H: 600} | |
| 489 | + | c.refreshShown() | |
| 490 | + | ctx := context.Background() | |
| 491 | + | b.ReportAllocs() | |
| 492 | + | b.ResetTimer() | |
| 493 | + | for range b.N { | |
| 494 | + | c.refreshShown() | |
| 495 | + | c.advanceFlash(nil) | |
| 496 | + | scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil) | |
| 497 | + | if err != nil { | |
| 498 | + | b.Fatal(err) | |
| 499 | + | } | |
| 500 | + | c.cur().last = scene | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) { | |
| 505 | + | for _, tc := range []struct { | |
| 506 | + | name string | |
| 507 | + | overflow Overflow | |
| 508 | + | action Action | |
| 509 | + | }{ | |
| 510 | + | {"vertical", OverflowScroll, ActionFocusWindowDown}, | |
| 511 | + | {"horizontal", OverflowCascade, ActionFocusColumnRight}, | |
| 512 | + | } { | |
| 513 | + | t.Run(tc.name, func(t *testing.T) { | |
| 514 | + | c, _ := flashCore(t) | |
| 515 | + | m := c.cur().mon | |
| 516 | + | m.SetOverflow(tc.overflow) | |
| 517 | + | before := m.Current() | |
| 518 | + | m.Focus(1) | |
| 519 | + | m.AddWindow(4) | |
| 520 | + | m.Focus(0) | |
| 521 | + | c.applyAction(tc.action) | |
| 522 | + | if m.Current() == before { | |
| 523 | + | t.Fatal("setup: edge did not switch workspace") | |
| 524 | + | } | |
| 525 | + | if id, _ := m.Focused(); id != 4 { | |
| 526 | + | t.Fatalf("did not land on occupied workspace: %d", id) | |
| 527 | + | } | |
| 528 | + | if c.flash.pending != nil || c.flash.running != nil { | |
| 529 | + | t.Fatal("workspace switch flashed") | |
| 530 | + | } | |
| 531 | + | }) | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) { | |
| 536 | + | c, clock := flashCore(t) | |
| 537 | + | c.clientEvent(context.Background(), ports.WindowMapped{ID: 4}) | |
| 538 | + | if c.flash.pending != nil { | |
| 539 | + | t.Fatal("mapping requested flash") | |
| 540 | + | } | |
| 541 | + | c.applyAction(ActionToggleFloating) | |
| 542 | + | if c.flash.pending == nil || c.flash.pending.window != 4 { | |
| 543 | + | t.Fatal("explicit action did not request flash") | |
| 544 | + | } | |
| 545 | + | c.stopAnimations() | |
| 546 | + | clock.now = clock.now.Add(flashSettle) | |
| 547 | + | if !c.flashTick() { | |
| 548 | + | t.Fatal("freshly mapped target rejected") | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func TestFlashPendingTimerStopped(t *testing.T) { | |
| 553 | + | for _, reason := range []string{"replacement", "security", "run exit"} { | |
| 554 | + | t.Run(reason, func(t *testing.T) { | |
| 555 | + | c, clock := flashCore(t) | |
| 556 | + | timer := portsmocks.NewMockTimer(t) | |
| 557 | + | timer.EXPECT().C().Return(make(chan time.Time)).Once() | |
| 558 | + | timer.EXPECT().Stop().Return(true).Times(1) | |
| 559 | + | strictClock := portsmocks.NewMockClock(t) | |
| 560 | + | strictClock.EXPECT().Now().Return(clock.now).Maybe() | |
| 561 | + | strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once() | |
| 562 | + | c.opts.Clock = strictClock | |
| 563 | + | c.requestFlash(flashWindow, 3) | |
| 564 | + | // Only the pending flash timer belongs to the strict clock. | |
| 565 | + | c.opts.Clock = nil | |
| 566 | + | switch reason { | |
| 567 | + | case "replacement": | |
| 568 | + | c.requestFlash(flashWindow, 2) | |
| 569 | + | c.stopFlash() | |
| 570 | + | case "security": | |
| 571 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 572 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |
| 573 | + | c.opts.Security = gate | |
| 574 | + | c.syncSecurity() | |
| 575 | + | case "run exit": | |
| 576 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | + | cancel() | |
| 578 | + | if err := c.Run(ctx); err != nil { | |
| 579 | + | t.Fatal(err) | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | }) | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | func TestFlashUnchangedSampleIsReused(t *testing.T) { | |
| 587 | + | c, clock := flashCore(t) | |
| 588 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 589 | + | first := indicatorScene(t, c).Flash | |
| 590 | + | if first == nil { | |
| 591 | + | t.Fatal("missing flash") | |
| 592 | + | } | |
| 593 | + | if next := indicatorScene(t, c).Flash; next != first { | |
| 594 | + | t.Fatal("unchanged immutable sample not reused") | |
| 595 | + | } | |
| 596 | + | clock.now = clock.now.Add(time.Millisecond) | |
| 597 | + | if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity { | |
| 598 | + | t.Fatal("changed sample mutated prior scene") | |
| 599 | + | } | |
| 600 | + | } | |
| @@ -0,0 +1,600 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func flashCore(t *testing.T) (*Core, *indicatorClock) { | |||
| 13 | + | t.Helper() | |||
| 14 | + | c, clock := indicatorCore(t) | |||
| 15 | + | c.cfg.Animations.On = true | |||
| 16 | + | c.cfg.Focus.Flash = true | |||
| 17 | + | c.cfg.Focus.FlashWidth = 3 | |||
| 18 | + | c.cfg.Focus.FlashOpacity = 0.8 | |||
| 19 | + | c.cfg.Focus.FlashDuration = 400 * time.Millisecond | |||
| 20 | + | c.cfg.Border.Active = "#abcdef" | |||
| 21 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 22 | + | c.windows.mapped(ports.WindowMapped{ID: id}, clock.now.Add(-time.Minute)) | |||
| 23 | + | c.cur().mon.AddWindow(id) | |||
| 24 | + | } | |||
| 25 | + | return c, clock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func startFlash(t *testing.T, c *Core, clock *indicatorClock, level flashLevel, id WindowID) { | |||
| 29 | + | t.Helper() | |||
| 30 | + | c.requestFlash(level, id) | |||
| 31 | + | clock.now = clock.now.Add(flashSettle) | |||
| 32 | + | if !c.flashTick() { | |||
| 33 | + | t.Fatal("flash did not start") | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestFlashFadeAndOutputFrames(t *testing.T) { | |||
| 38 | + | c, clock := flashCore(t) | |||
| 39 | + | a := c.cur() | |||
| 40 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |||
| 41 | + | b := c.screens[1] | |||
| 42 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 43 | + | first := stepScenes(t, c, a) | |||
| 44 | + | f := first["A"].Flash | |||
| 45 | + | if f == nil || f.Alpha != 0.8 || f.Color != c.cfg.Border.Active || f.Width != 3 { | |||
| 46 | + | t.Fatalf("flash %+v", f) | |||
| 47 | + | } | |||
| 48 | + | if first["B"].Flash != nil || !first["A"].Animating || !c.animating() { | |||
| 49 | + | t.Fatal("output/animation routing") | |||
| 50 | + | } | |||
| 51 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |||
| 52 | + | other := stepScenes(t, c, b) | |||
| 53 | + | if other["A"].Seq != first["A"].Seq || *other["A"].Flash != *f { | |||
| 54 | + | t.Fatal("other output advanced flash") | |||
| 55 | + | } | |||
| 56 | + | half := stepScenes(t, c, a) | |||
| 57 | + | if half["A"].Flash.Alpha != 0.4 { | |||
| 58 | + | t.Fatalf("half alpha %v", half["A"].Flash.Alpha) | |||
| 59 | + | } | |||
| 60 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |||
| 61 | + | end := stepScenes(t, c, a) | |||
| 62 | + | if end["A"].Flash != nil || end["A"].Animating || c.animating() { | |||
| 63 | + | t.Fatal("flash did not stop frames") | |||
| 64 | + | } | |||
| 65 | + | if end["A"].Seq == half["A"].Seq { | |||
| 66 | + | t.Fatal("end retained animating sequence") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestFlashDebounceCooldownAndReplacement(t *testing.T) { | |||
| 71 | + | c, clock := flashCore(t) | |||
| 72 | + | c.requestFlash(flashWindow, 1) | |||
| 73 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |||
| 74 | + | c.requestFlash(flashWindow, 2) | |||
| 75 | + | clock.now = clock.now.Add(50 * time.Millisecond) | |||
| 76 | + | if c.flashTick() || c.flash.pending == nil { | |||
| 77 | + | t.Fatal("early stale timer started flash") | |||
| 78 | + | } | |||
| 79 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |||
| 80 | + | if !c.flashTick() || c.flash.running.window != 2 { | |||
| 81 | + | t.Fatal("last request did not win") | |||
| 82 | + | } | |||
| 83 | + | c.requestFlash(flashWindow, 2) | |||
| 84 | + | if c.flash.running != nil { | |||
| 85 | + | t.Fatal("request did not replace running flash") | |||
| 86 | + | } | |||
| 87 | + | clock.now = clock.now.Add(flashSettle) | |||
| 88 | + | if c.flashTick() { | |||
| 89 | + | t.Fatal("cooldown ignored") | |||
| 90 | + | } | |||
| 91 | + | clock.now = clock.now.Add(time.Second) | |||
| 92 | + | startFlash(t, c, clock, flashWindow, 2) | |||
| 93 | + | c.requestFlash(flashColumn, 2) | |||
| 94 | + | clock.now = clock.now.Add(flashSettle) | |||
| 95 | + | if !c.flashTick() || c.flash.running.level != flashColumn { | |||
| 96 | + | t.Fatal("level not part of cooldown key") | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestFlashSettlesAndResolvesFinalRect(t *testing.T) { | |||
| 101 | + | for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} { | |||
| 102 | + | t.Run(string(rune('0'+level)), func(t *testing.T) { | |||
| 103 | + | c, clock := flashCore(t) | |||
| 104 | + | w := c.cur().mon.Current() | |||
| 105 | + | c.requestFlash(level, 3) | |||
| 106 | + | w.view.off = 20 | |||
| 107 | + | clock.now = clock.now.Add(flashSettle) | |||
| 108 | + | if c.flashTick() || c.flash.timerC == nil { | |||
| 109 | + | t.Fatal("did not wait for layout") | |||
| 110 | + | } | |||
| 111 | + | w.view.stop() | |||
| 112 | + | w.MoveColumn(-1) | |||
| 113 | + | clock.now = clock.now.Add(flashRecheck) | |||
| 114 | + | if !c.flashTick() { | |||
| 115 | + | t.Fatal("did not start after settling") | |||
| 116 | + | } | |||
| 117 | + | want := Rect{W: 300, H: 200} | |||
| 118 | + | if level != flashMonitor { | |||
| 119 | + | for _, p := range c.cur().mon.Layout() { | |||
| 120 | + | if p.ID == 3 { | |||
| 121 | + | want = p.Rect | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | if c.flash.rect != want { | |||
| 126 | + | t.Fatalf("rect %+v want %+v", c.flash.rect, want) | |||
| 127 | + | } | |||
| 128 | + | }) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestFlashSkipsIneligibleTargets(t *testing.T) { | |||
| 133 | + | for _, test := range []struct { | |||
| 134 | + | name string | |||
| 135 | + | change func(*Core, *indicatorClock) | |||
| 136 | + | }{ | |||
| 137 | + | {"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }}, | |||
| 138 | + | {"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }}, | |||
| 139 | + | {"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }}, | |||
| 140 | + | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | |||
| 141 | + | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | |||
| 142 | + | {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }}, | |||
| 143 | + | } { | |||
| 144 | + | t.Run(test.name, func(t *testing.T) { | |||
| 145 | + | c, clock := flashCore(t) | |||
| 146 | + | test.change(c, clock) | |||
| 147 | + | c.requestFlash(flashWindow, 3) | |||
| 148 | + | clock.now = clock.now.Add(flashSettle) | |||
| 149 | + | if c.flashTick() || c.flash.running != nil { | |||
| 150 | + | t.Fatal("ineligible flash") | |||
| 151 | + | } | |||
| 152 | + | }) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestFlashStopAndColor(t *testing.T) { | |||
| 157 | + | c, clock := flashCore(t) | |||
| 158 | + | c.cfg.Focus.FlashColor = "#123456" | |||
| 159 | + | c.cfg.Animations.Slowdown = 10 | |||
| 160 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 161 | + | if s := indicatorScene(t, c); s.Flash.Color != "#123456" { | |||
| 162 | + | t.Fatal("configured color ignored") | |||
| 163 | + | } | |||
| 164 | + | clock.now = clock.now.Add(c.cfg.Focus.FlashDuration) | |||
| 165 | + | if indicatorScene(t, c).Flash != nil { | |||
| 166 | + | t.Fatal("duration scaled by slowdown") | |||
| 167 | + | } | |||
| 168 | + | c.requestFlash(flashWindow, 2) | |||
| 169 | + | c.stopFlash() | |||
| 170 | + | if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil { | |||
| 171 | + | t.Fatal("stop retained state") | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestFlashActionTriggers(t *testing.T) { | |||
| 176 | + | for _, test := range []struct { | |||
| 177 | + | action Action | |||
| 178 | + | level flashLevel | |||
| 179 | + | setup func(*Core) | |||
| 180 | + | }{ | |||
| 181 | + | {ActionFocusColumnLeft, flashColumn, nil}, | |||
| 182 | + | {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |||
| 183 | + | {ActionMoveColumnLeft, flashColumn, nil}, | |||
| 184 | + | {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |||
| 185 | + | {ActionConsumeOrExpelLeft, flashWindow, nil}, | |||
| 186 | + | {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |||
| 187 | + | {ActionToggleFloating, flashWindow, nil}, | |||
| 188 | + | {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |||
| 189 | + | {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |||
| 190 | + | {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |||
| 191 | + | {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |||
| 192 | + | {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }}, | |||
| 193 | + | {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }}, | |||
| 194 | + | {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }}, | |||
| 195 | + | } { | |||
| 196 | + | t.Run(string(test.action), func(t *testing.T) { | |||
| 197 | + | c, clock := flashCore(t) | |||
| 198 | + | if test.setup != nil { | |||
| 199 | + | test.setup(c) | |||
| 200 | + | } | |||
| 201 | + | c.applyAction(test.action) | |||
| 202 | + | if c.flash.pending == nil || c.flash.pending.level != test.level { | |||
| 203 | + | t.Fatalf("pending %+v want level %v", c.flash.pending, test.level) | |||
| 204 | + | } | |||
| 205 | + | target := *c.flash.pending | |||
| 206 | + | clock.now = clock.now.Add(flashSettle) | |||
| 207 | + | if !c.flashTick() { | |||
| 208 | + | t.Fatal("action flash did not start") | |||
| 209 | + | } | |||
| 210 | + | var want Rect | |||
| 211 | + | for _, p := range c.cur().mon.Layout() { | |||
| 212 | + | if p.ID == target.window { | |||
| 213 | + | want = p.Rect | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | if c.flash.rect != want { | |||
| 217 | + | t.Fatalf("action rect %+v want %+v", c.flash.rect, want) | |||
| 218 | + | } | |||
| 219 | + | }) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestFlashExcludedActions(t *testing.T) { | |||
| 224 | + | for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} { | |||
| 225 | + | t.Run(string(action), func(t *testing.T) { | |||
| 226 | + | c, _ := flashCore(t) | |||
| 227 | + | c.applyAction(action) | |||
| 228 | + | if c.flash.pending != nil { | |||
| 229 | + | t.Fatalf("unexpected flash %+v", c.flash.pending) | |||
| 230 | + | } | |||
| 231 | + | }) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestFlashMonitorTriggers(t *testing.T) { | |||
| 236 | + | for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} { | |||
| 237 | + | t.Run(string(action), func(t *testing.T) { | |||
| 238 | + | c, clock := flashCore(t) | |||
| 239 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |||
| 240 | + | a, b := c.screens[0], c.screens[1] | |||
| 241 | + | a.x, a.y = 0, 0 | |||
| 242 | + | b.x, b.y = 300, 0 | |||
| 243 | + | switch monitorDirection(action) { | |||
| 244 | + | case dirLeft: | |||
| 245 | + | b.x = -400 | |||
| 246 | + | case dirUp: | |||
| 247 | + | b.x, b.y = 0, -300 | |||
| 248 | + | case dirDown: | |||
| 249 | + | b.x, b.y = 0, 200 | |||
| 250 | + | } | |||
| 251 | + | c.applyAction(action) | |||
| 252 | + | if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" { | |||
| 253 | + | t.Fatalf("destination request %+v", c.flash.pending) | |||
| 254 | + | } | |||
| 255 | + | clock.now = clock.now.Add(flashSettle) | |||
| 256 | + | if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) { | |||
| 257 | + | t.Fatalf("monitor rect %+v", c.flash.rect) | |||
| 258 | + | } | |||
| 259 | + | }) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestFlashColumnUnion(t *testing.T) { | |||
| 264 | + | c, clock := flashCore(t) | |||
| 265 | + | w := c.cur().mon.Current() | |||
| 266 | + | w.ConsumeOrExpel(-1) | |||
| 267 | + | startFlash(t, c, clock, flashColumn, 3) | |||
| 268 | + | col := w.Columns[w.columnOf(3)] | |||
| 269 | + | var area int | |||
| 270 | + | for _, p := range c.cur().mon.Layout() { | |||
| 271 | + | for _, id := range col.Windows { | |||
| 272 | + | if id == p.ID { | |||
| 273 | + | area += p.Rect.W * p.Rect.H | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | if c.flash.rect.W*c.flash.rect.H != area { | |||
| 278 | + | t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func TestFlashGestureLanding(t *testing.T) { | |||
| 283 | + | c, _ := flashCore(t) | |||
| 284 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 285 | + | c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond}) | |||
| 286 | + | c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond}) | |||
| 287 | + | if c.flash.pending == nil || c.flash.pending.level != flashColumn { | |||
| 288 | + | t.Fatalf("landing request %+v", c.flash.pending) | |||
| 289 | + | } | |||
| 290 | + | c.stopFlash() | |||
| 291 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond}) | |||
| 292 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond}) | |||
| 293 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond}) | |||
| 294 | + | if c.flash.pending != nil { | |||
| 295 | + | t.Fatal("cancelled gesture flashed") | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func TestFlashLockClearsPendingAndRunning(t *testing.T) { | |||
| 300 | + | for _, running := range []bool{false, true} { | |||
| 301 | + | c, clock := flashCore(t) | |||
| 302 | + | state := ports.SecurityState{} | |||
| 303 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 304 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |||
| 305 | + | c.opts.Security = gate | |||
| 306 | + | if running { | |||
| 307 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 308 | + | } else { | |||
| 309 | + | c.requestFlash(flashWindow, 3) | |||
| 310 | + | } | |||
| 311 | + | state = ports.SecurityState{Generation: 1, Protected: true} | |||
| 312 | + | if !c.syncSecurity() { | |||
| 313 | + | t.Fatal("security did not change") | |||
| 314 | + | } | |||
| 315 | + | if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil { | |||
| 316 | + | t.Fatal("lock retained flash") | |||
| 317 | + | } | |||
| 318 | + | state = ports.SecurityState{Generation: 2} | |||
| 319 | + | c.syncSecurity() | |||
| 320 | + | if indicatorScene(t, c).Flash != nil || c.flash.pending != nil { | |||
| 321 | + | t.Fatal("unlock requested flash") | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func TestFlashOverviewClickAndKeyboard(t *testing.T) { | |||
| 327 | + | for _, click := range []bool{true, false} { | |||
| 328 | + | c, _ := flashCore(t) | |||
| 329 | + | c.cur().mon.ToggleOverview() | |||
| 330 | + | scene := indicatorScene(t, c) | |||
| 331 | + | if click { | |||
| 332 | + | var rect Rect | |||
| 333 | + | for _, p := range scene.Windows { | |||
| 334 | + | if p.ID == 2 { | |||
| 335 | + | rect = p.Rect | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |||
| 339 | + | picked, err := c.overviewClick(context.Background()) | |||
| 340 | + | if err != nil || !picked { | |||
| 341 | + | t.Fatalf("click %v %v", picked, err) | |||
| 342 | + | } | |||
| 343 | + | } else { | |||
| 344 | + | if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil { | |||
| 345 | + | t.Fatal(err) | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow { | |||
| 349 | + | t.Fatalf("overview request %+v", c.flash.pending) | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestFlashTileDrop(t *testing.T) { | |||
| 355 | + | c, _ := flashCore(t) | |||
| 356 | + | scene := indicatorScene(t, c) | |||
| 357 | + | var rect Rect | |||
| 358 | + | for _, p := range scene.Windows { | |||
| 359 | + | if p.ID == 2 { | |||
| 360 | + | rect = p.Rect | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |||
| 364 | + | c.drag = &dragState{id: 3} | |||
| 365 | + | if err := c.endDrag(context.Background()); err != nil { | |||
| 366 | + | t.Fatal(err) | |||
| 367 | + | } | |||
| 368 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 { | |||
| 369 | + | t.Fatalf("drop request %+v", c.flash.pending) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func TestFlashNoImplicitFocusTriggers(t *testing.T) { | |||
| 374 | + | c, _ := flashCore(t) | |||
| 375 | + | ctx := context.Background() | |||
| 376 | + | indicatorScene(t, c) | |||
| 377 | + | if c.flash.pending != nil { | |||
| 378 | + | t.Fatal("startup publication flashed") | |||
| 379 | + | } | |||
| 380 | + | for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} { | |||
| 381 | + | if _, stop := c.clientEvent(ctx, ev); stop { | |||
| 382 | + | t.Fatalf("event %T stopped", ev) | |||
| 383 | + | } | |||
| 384 | + | if c.flash.pending != nil { | |||
| 385 | + | t.Fatalf("event %T flashed", ev) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | indicatorScene(t, c) | |||
| 389 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | |||
| 390 | + | t.Fatal(err) | |||
| 391 | + | } | |||
| 392 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |||
| 393 | + | t.Fatal(err) | |||
| 394 | + | } | |||
| 395 | + | if c.flash.pending != nil { | |||
| 396 | + | t.Fatal("pointer focus flashed") | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func TestFlashCancelsStaleRunningTargets(t *testing.T) { | |||
| 401 | + | for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} { | |||
| 402 | + | for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} { | |||
| 403 | + | t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) { | |||
| 404 | + | c, clock := flashCore(t) | |||
| 405 | + | a := c.cur() | |||
| 406 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 407 | + | b := c.screens[1] | |||
| 408 | + | startFlash(t, c, clock, level, 3) | |||
| 409 | + | if indicatorScene(t, c).Flash == nil { | |||
| 410 | + | t.Fatal("setup: no running flash") | |||
| 411 | + | } | |||
| 412 | + | switch change { | |||
| 413 | + | case "overview": | |||
| 414 | + | a.mon.ToggleOverview() | |||
| 415 | + | case "output gone": | |||
| 416 | + | c.screens = []*screen{b} | |||
| 417 | + | c.focusScreen = 0 | |||
| 418 | + | case "fullscreen": | |||
| 419 | + | a.mon.ToggleFullscreen() | |||
| 420 | + | case "unmapped": | |||
| 421 | + | c.windows.drop(3) | |||
| 422 | + | case "hidden": | |||
| 423 | + | w := a.mon.Current() | |||
| 424 | + | w.ToggleWindowStash() | |||
| 425 | + | w.ToggleStashVisible() | |||
| 426 | + | case "other output": | |||
| 427 | + | a.mon.RemoveWindow(3) | |||
| 428 | + | b.mon.AddWindow(3) | |||
| 429 | + | case "workspace changed": | |||
| 430 | + | a.mon.Focus(a.mon.Active + 1) | |||
| 431 | + | } | |||
| 432 | + | // Cancellation must not wait for the target output's next flip. | |||
| 433 | + | scenes := stepScenes(t, c, b) | |||
| 434 | + | shouldCancel := level != flashMonitor || change == "overview" || change == "output gone" | |||
| 435 | + | if shouldCancel { | |||
| 436 | + | if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() { | |||
| 437 | + | t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating()) | |||
| 438 | + | } | |||
| 439 | + | } else if scenes["A"].Flash == nil || !c.animating() { | |||
| 440 | + | t.Fatal("monitor flash cancelled for a window change") | |||
| 441 | + | } | |||
| 442 | + | }) | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func TestFlashCooldownRemembersEveryTarget(t *testing.T) { | |||
| 448 | + | c, clock := flashCore(t) | |||
| 449 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 450 | + | first := c.flash.start | |||
| 451 | + | startFlash(t, c, clock, flashWindow, 2) | |||
| 452 | + | c.requestFlash(flashWindow, 3) | |||
| 453 | + | clock.now = clock.now.Add(flashSettle) | |||
| 454 | + | if c.flashTick() || c.flash.running != nil { | |||
| 455 | + | t.Fatal("A flashed again after B within cooldown") | |||
| 456 | + | } | |||
| 457 | + | clock.now = first.Add(flashCooldown) | |||
| 458 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 459 | + | if c.flash.running.window != 3 { | |||
| 460 | + | t.Fatal("A did not flash after cooldown") | |||
| 461 | + | } | |||
| 462 | + | // Inserting after all previous starts expire prunes old target keys. | |||
| 463 | + | clock.now = clock.now.Add(flashCooldown) | |||
| 464 | + | startFlash(t, c, clock, flashMonitor, 0) | |||
| 465 | + | if len(c.flash.lastStarts) != 1 { | |||
| 466 | + | t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts) | |||
| 467 | + | } | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // BenchmarkFlashSceneBuild includes revalidation and immutable scene creation. | |||
| 471 | + | func BenchmarkFlashSceneBuild(b *testing.B) { | |||
| 472 | + | var cfg ports.Config | |||
| 473 | + | cfg.Layout.MaxColumns = 2 | |||
| 474 | + | cfg.Keyboard.CmdKey = "super" | |||
| 475 | + | cfg.Animations.On = true | |||
| 476 | + | cfg.Focus.Flash = true | |||
| 477 | + | cfg.Focus.FlashOpacity = 0.8 | |||
| 478 | + | cfg.Focus.FlashDuration = time.Hour | |||
| 479 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{}) | |||
| 480 | + | if err != nil { | |||
| 481 | + | b.Fatal(err) | |||
| 482 | + | } | |||
| 483 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600}) | |||
| 484 | + | c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now()) | |||
| 485 | + | c.cur().mon.AddWindow(1) | |||
| 486 | + | c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"} | |||
| 487 | + | c.flash.start = time.Now() | |||
| 488 | + | c.flash.rect = Rect{W: 1000, H: 600} | |||
| 489 | + | c.refreshShown() | |||
| 490 | + | ctx := context.Background() | |||
| 491 | + | b.ReportAllocs() | |||
| 492 | + | b.ResetTimer() | |||
| 493 | + | for range b.N { | |||
| 494 | + | c.refreshShown() | |||
| 495 | + | c.advanceFlash(nil) | |||
| 496 | + | scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil) | |||
| 497 | + | if err != nil { | |||
| 498 | + | b.Fatal(err) | |||
| 499 | + | } | |||
| 500 | + | c.cur().last = scene | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) { | |||
| 505 | + | for _, tc := range []struct { | |||
| 506 | + | name string | |||
| 507 | + | overflow Overflow | |||
| 508 | + | action Action | |||
| 509 | + | }{ | |||
| 510 | + | {"vertical", OverflowScroll, ActionFocusWindowDown}, | |||
| 511 | + | {"horizontal", OverflowCascade, ActionFocusColumnRight}, | |||
| 512 | + | } { | |||
| 513 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 514 | + | c, _ := flashCore(t) | |||
| 515 | + | m := c.cur().mon | |||
| 516 | + | m.SetOverflow(tc.overflow) | |||
| 517 | + | before := m.Current() | |||
| 518 | + | m.Focus(1) | |||
| 519 | + | m.AddWindow(4) | |||
| 520 | + | m.Focus(0) | |||
| 521 | + | c.applyAction(tc.action) | |||
| 522 | + | if m.Current() == before { | |||
| 523 | + | t.Fatal("setup: edge did not switch workspace") | |||
| 524 | + | } | |||
| 525 | + | if id, _ := m.Focused(); id != 4 { | |||
| 526 | + | t.Fatalf("did not land on occupied workspace: %d", id) | |||
| 527 | + | } | |||
| 528 | + | if c.flash.pending != nil || c.flash.running != nil { | |||
| 529 | + | t.Fatal("workspace switch flashed") | |||
| 530 | + | } | |||
| 531 | + | }) | |||
| 532 | + | } | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) { | |||
| 536 | + | c, clock := flashCore(t) | |||
| 537 | + | c.clientEvent(context.Background(), ports.WindowMapped{ID: 4}) | |||
| 538 | + | if c.flash.pending != nil { | |||
| 539 | + | t.Fatal("mapping requested flash") | |||
| 540 | + | } | |||
| 541 | + | c.applyAction(ActionToggleFloating) | |||
| 542 | + | if c.flash.pending == nil || c.flash.pending.window != 4 { | |||
| 543 | + | t.Fatal("explicit action did not request flash") | |||
| 544 | + | } | |||
| 545 | + | c.stopAnimations() | |||
| 546 | + | clock.now = clock.now.Add(flashSettle) | |||
| 547 | + | if !c.flashTick() { | |||
| 548 | + | t.Fatal("freshly mapped target rejected") | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func TestFlashPendingTimerStopped(t *testing.T) { | |||
| 553 | + | for _, reason := range []string{"replacement", "security", "run exit"} { | |||
| 554 | + | t.Run(reason, func(t *testing.T) { | |||
| 555 | + | c, clock := flashCore(t) | |||
| 556 | + | timer := portsmocks.NewMockTimer(t) | |||
| 557 | + | timer.EXPECT().C().Return(make(chan time.Time)).Once() | |||
| 558 | + | timer.EXPECT().Stop().Return(true).Times(1) | |||
| 559 | + | strictClock := portsmocks.NewMockClock(t) | |||
| 560 | + | strictClock.EXPECT().Now().Return(clock.now).Maybe() | |||
| 561 | + | strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once() | |||
| 562 | + | c.opts.Clock = strictClock | |||
| 563 | + | c.requestFlash(flashWindow, 3) | |||
| 564 | + | // Only the pending flash timer belongs to the strict clock. | |||
| 565 | + | c.opts.Clock = nil | |||
| 566 | + | switch reason { | |||
| 567 | + | case "replacement": | |||
| 568 | + | c.requestFlash(flashWindow, 2) | |||
| 569 | + | c.stopFlash() | |||
| 570 | + | case "security": | |||
| 571 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 572 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 573 | + | c.opts.Security = gate | |||
| 574 | + | c.syncSecurity() | |||
| 575 | + | case "run exit": | |||
| 576 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 577 | + | cancel() | |||
| 578 | + | if err := c.Run(ctx); err != nil { | |||
| 579 | + | t.Fatal(err) | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | }) | |||
| 583 | + | } | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | func TestFlashUnchangedSampleIsReused(t *testing.T) { | |||
| 587 | + | c, clock := flashCore(t) | |||
| 588 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 589 | + | first := indicatorScene(t, c).Flash | |||
| 590 | + | if first == nil { | |||
| 591 | + | t.Fatal("missing flash") | |||
| 592 | + | } | |||
| 593 | + | if next := indicatorScene(t, c).Flash; next != first { | |||
| 594 | + | t.Fatal("unchanged immutable sample not reused") | |||
| 595 | + | } | |||
| 596 | + | clock.now = clock.now.Add(time.Millisecond) | |||
| 597 | + | if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity { | |||
| 598 | + | t.Fatal("changed sample mutated prior scene") | |||
| 599 | + | } | |||
| 600 | + | } | |||
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 +17 -5
| @@ -315,12 +315,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||
| 315 | 315 | if focus != w.Focus && w.policy().maximizeHides() { | |
| 316 | 316 | w.unmaximize() | |
| 317 | 317 | } | |
| 318 | + | changed := w.Focus != focus | |
| 318 | 319 | w.Focus = focus | |
| 319 | 320 | w.View = view | |
| 320 | 321 | // A column wider than the view aligns as focus moves align it. | |
| 321 | 322 | w.scroll() | |
| 322 | 323 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 323 | 324 | c.keyboard.takeBack() | |
| 325 | + | if changed { | |
| 326 | + | id, _ := w.Focused() | |
| 327 | + | c.requestFlash(flashColumn, id) | |
| 328 | + | } | |
| 324 | 329 | } | |
| 325 | 330 | w.view.off = shown - float64(w.View) | |
| 326 | 331 | if c.animOn() { | |
| @@ -399,10 +404,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||
| 399 | 404 | } | |
| 400 | 405 | before := m.Current() | |
| 401 | 406 | shots := c.snapshot(now) | |
| 407 | + | closing := m.ov.open | |
| 402 | 408 | m.ToggleOverview() | |
| 403 | 409 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 404 | 410 | c.keyboard.takeBack() | |
| 405 | 411 | c.transition(shots, now) | |
| 412 | + | if closing { | |
| 413 | + | id, _ := m.Focused() | |
| 414 | + | if id != 0 { | |
| 415 | + | c.requestFlash(flashWindow, id) | |
| 416 | + | } | |
| 417 | + | } | |
| 406 | 418 | return m.Current() != before, nil | |
| 407 | 419 | case swipeDiscrete: | |
| 408 | 420 | step := g.snap.step(e.Cancelled, e.Time) | |
| @@ -574,15 +586,15 @@ func (c *Core) animate(now time.Time, only *screen) { | |||
| 574 | 586 | } | |
| 575 | 587 | } | |
| 576 | 588 | ||
| 577 | - | // sliding reports whether a spring runs on the named output. | |
| 589 | + | // sliding reports whether a spring or focus flash runs on the named output. | |
| 578 | 590 | func (c *Core) sliding(output string) bool { | |
| 579 | 591 | i := c.screenIndex(output) | |
| 580 | - | return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i])) | |
| 592 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) | |
| 581 | 593 | } | |
| 582 | 594 | ||
| 583 | - | // animating reports whether a spring or the focus pulse runs on any output. | |
| 595 | + | // animating reports whether a spring or focus flash runs on any output. | |
| 584 | 596 | func (c *Core) animating() bool { | |
| 585 | - | if c.pulse.id != 0 { | |
| 597 | + | if c.flash.running != nil { | |
| 586 | 598 | return true | |
| 587 | 599 | } | |
| 588 | 600 | for _, sc := range c.screens { | |
| @@ -610,7 +622,7 @@ func (m *Monitor) springing() bool { | |||
| 610 | 622 | func (c *Core) frameFallback() time.Duration { | |
| 611 | 623 | refresh := 60000 | |
| 612 | 624 | for _, sc := range c.screens { | |
| 613 | - | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) { | |
| 625 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { | |
| 614 | 626 | refresh = min(refresh, r) | |
| 615 | 627 | } | |
| 616 | 628 | } | |
| @@ -315,12 +315,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||||
| 315 | if focus != w.Focus && w.policy().maximizeHides() { | 315 | if focus != w.Focus && w.policy().maximizeHides() { | ||
| 316 | w.unmaximize() | 316 | w.unmaximize() | ||
| 317 | } | 317 | } | ||
| 318 | + | changed := w.Focus != focus | |||
| 318 | w.Focus = focus | 319 | w.Focus = focus | ||
| 319 | w.View = view | 320 | w.View = view | ||
| 320 | // A column wider than the view aligns as focus moves align it. | 321 | // A column wider than the view aligns as focus moves align it. | ||
| 321 | w.scroll() | 322 | w.scroll() | ||
| 322 | c.focusScreen = c.screenIndex(g.screen.name()) | 323 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 323 | c.keyboard.takeBack() | 324 | c.keyboard.takeBack() | ||
| 325 | + | if changed { | |||
| 326 | + | id, _ := w.Focused() | |||
| 327 | + | c.requestFlash(flashColumn, id) | |||
| 328 | + | } | |||
| 324 | } | 329 | } | ||
| 325 | w.view.off = shown - float64(w.View) | 330 | w.view.off = shown - float64(w.View) | ||
| 326 | if c.animOn() { | 331 | if c.animOn() { | ||
| @@ -399,10 +404,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||||
| 399 | } | 404 | } | ||
| 400 | before := m.Current() | 405 | before := m.Current() | ||
| 401 | shots := c.snapshot(now) | 406 | shots := c.snapshot(now) | ||
| 407 | + | closing := m.ov.open | |||
| 402 | m.ToggleOverview() | 408 | m.ToggleOverview() | ||
| 403 | c.focusScreen = c.screenIndex(g.screen.name()) | 409 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 404 | c.keyboard.takeBack() | 410 | c.keyboard.takeBack() | ||
| 405 | c.transition(shots, now) | 411 | c.transition(shots, now) | ||
| 412 | + | if closing { | |||
| 413 | + | id, _ := m.Focused() | |||
| 414 | + | if id != 0 { | |||
| 415 | + | c.requestFlash(flashWindow, id) | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 406 | return m.Current() != before, nil | 418 | return m.Current() != before, nil | ||
| 407 | case swipeDiscrete: | 419 | case swipeDiscrete: | ||
| 408 | step := g.snap.step(e.Cancelled, e.Time) | 420 | step := g.snap.step(e.Cancelled, e.Time) | ||
| @@ -574,15 +586,15 @@ func (c *Core) animate(now time.Time, only *screen) { | |||||
| 574 | } | 586 | } | ||
| 575 | } | 587 | } | ||
| 576 | 588 | ||||
| 577 | - | // sliding reports whether a spring runs on the named output. | 589 | + | // sliding reports whether a spring or focus flash runs on the named output. |
| 578 | func (c *Core) sliding(output string) bool { | 590 | func (c *Core) sliding(output string) bool { | ||
| 579 | i := c.screenIndex(output) | 591 | i := c.screenIndex(output) | ||
| 580 | - | return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i])) | 592 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) |
| 581 | } | 593 | } | ||
| 582 | 594 | ||||
| 583 | - | // animating reports whether a spring or the focus pulse runs on any output. | 595 | + | // animating reports whether a spring or focus flash runs on any output. |
| 584 | func (c *Core) animating() bool { | 596 | func (c *Core) animating() bool { | ||
| 585 | - | if c.pulse.id != 0 { | 597 | + | if c.flash.running != nil { |
| 586 | return true | 598 | return true | ||
| 587 | } | 599 | } | ||
| 588 | for _, sc := range c.screens { | 600 | for _, sc := range c.screens { | ||
| @@ -610,7 +622,7 @@ func (m *Monitor) springing() bool { | |||||
| 610 | func (c *Core) frameFallback() time.Duration { | 622 | func (c *Core) frameFallback() time.Duration { | ||
| 611 | refresh := 60000 | 623 | refresh := 60000 | ||
| 612 | for _, sc := range c.screens { | 624 | for _, sc := range c.screens { | ||
| 613 | - | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) { | 625 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { |
| 614 | refresh = min(refresh, r) | 626 | refresh = min(refresh, r) | ||
| 615 | } | 627 | } | ||
| 616 | } | 628 | } | ||
M internal/core/input.go +6 -0
| @@ -287,6 +287,12 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||
| 287 | 287 | shots = c.snapshot(now) | |
| 288 | 288 | } | |
| 289 | 289 | if key.Pressed && mon.overviewKey(key) { | |
| 290 | + | if !mon.ov.open && keyName(key.Keysym) != "Escape" { | |
| 291 | + | id, _ := mon.Focused() | |
| 292 | + | if id != 0 { | |
| 293 | + | c.requestFlash(flashWindow, id) | |
| 294 | + | } | |
| 295 | + | } | |
| 290 | 296 | c.pressed[heldKey(key)] = true | |
| 291 | 297 | if mon.ov.open && overviewRepeats(key) { | |
| 292 | 298 | c.startRepeat(key, "") | |
| @@ -287,6 +287,12 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||||
| 287 | shots = c.snapshot(now) | 287 | shots = c.snapshot(now) | ||
| 288 | } | 288 | } | ||
| 289 | if key.Pressed && mon.overviewKey(key) { | 289 | if key.Pressed && mon.overviewKey(key) { | ||
| 290 | + | if !mon.ov.open && keyName(key.Keysym) != "Escape" { | |||
| 291 | + | id, _ := mon.Focused() | |||
| 292 | + | if id != 0 { | |||
| 293 | + | c.requestFlash(flashWindow, id) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 290 | c.pressed[heldKey(key)] = true | 296 | c.pressed[heldKey(key)] = true | ||
| 291 | if mon.ov.open && overviewRepeats(key) { | 297 | if mon.ov.open && overviewRepeats(key) { | ||
| 292 | c.startRepeat(key, "") | 298 | c.startRepeat(key, "") | ||
M internal/core/overview.go +1 -0
| @@ -1476,6 +1476,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 1476 | 1476 | sc.mon.OverviewPick(id) | |
| 1477 | 1477 | c.transition(shots, now) | |
| 1478 | 1478 | c.focusScreen = i | |
| 1479 | + | c.requestFlash(flashWindow, id) | |
| 1479 | 1480 | c.keyboard.takeBack() | |
| 1480 | 1481 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 1481 | 1482 | return true, err | |
| @@ -1476,6 +1476,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 1476 | sc.mon.OverviewPick(id) | 1476 | sc.mon.OverviewPick(id) | ||
| 1477 | c.transition(shots, now) | 1477 | c.transition(shots, now) | ||
| 1478 | c.focusScreen = i | 1478 | c.focusScreen = i | ||
| 1479 | + | c.requestFlash(flashWindow, id) | |||
| 1479 | c.keyboard.takeBack() | 1480 | c.keyboard.takeBack() | ||
| 1480 | if err := c.workspaceVisible(ctx, true); err != nil { | 1481 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
| 1481 | return true, err | 1482 | return true, err | ||
M internal/core/persist_test.go +1 -1
| @@ -22,7 +22,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock { | |||
| 22 | 22 | now = now.Add(time.Second) | |
| 23 | 23 | return now | |
| 24 | 24 | }).Maybe() | |
| 25 | - | // Timers (the focus pulse) never fire: tests drive events only. | |
| 25 | + | // Timers never fire: tests drive events only. | |
| 26 | 26 | clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer { | |
| 27 | 27 | timer := portsmocks.NewMockTimer(t) | |
| 28 | 28 | 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 now | 23 | 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 +4 -12
| @@ -8,8 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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. | |
| 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) { | |
| 13 | 12 | o := sc.mon.Output() | |
| 14 | 13 | // view.frame is the viewport of the workspace on screen: the whole | |
| 15 | 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 | 67 | // Only the focused output lights the focused window's lines. | |
| 69 | 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 | 70 | for k, p := range layout { | |
| 74 | 71 | ps := settled[k] | |
| 75 | 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 | 95 | 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 | 97 | scene.Windows = append(scene.Windows, sw) | |
| 107 | 98 | t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID} | |
| 108 | 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 | 111 | cp, t := c.captureConfigure(sc, ps, t) | |
| 121 | 112 | if v, send := c.configures.nextWithCapture(ps, t, cp); send { | |
| 122 | 113 | if err := c.command(ctx, v); err != nil { | |
| 123 | - | return ports.Scene{}, false, err | |
| 114 | + | return ports.Scene{}, err | |
| 124 | 115 | } | |
| 125 | 116 | c.configures.mark(v) | |
| 126 | 117 | } | |
| @@ -130,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 130 | 121 | // The drag icon is over everything, popups and layers included. | |
| 131 | 122 | scene.Windows = append(scene.Windows, icon) | |
| 132 | 123 | } | |
| 124 | + | scene.Flash = c.flashFor(sc) | |
| 133 | 125 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 134 | 126 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 135 | 127 | // The springs of this frame are advanced: a scene shown while one still | |
| @@ -160,5 +152,5 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 160 | 152 | scene.CaptureScene = c.captureWindowScene(capture, scene.Seq) | |
| 161 | 153 | } | |
| 162 | 154 | } | |
| 163 | - | return scene, drawable, nil | |
| 155 | + | return scene, nil | |
| 164 | 156 | } | |
| @@ -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 whole | 13 | // 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 | continue | 95 | 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, err | 114 | + | return ports.Scene{}, err |
| 124 | } | 115 | } | ||
| 125 | c.configures.mark(v) | 116 | c.configures.mark(v) | ||
| 126 | } | 117 | } | ||
| @@ -130,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 130 | // The drag icon is over everything, popups and layers included. | 121 | // The drag icon is over everything, popups and layers included. | ||
| 131 | scene.Windows = append(scene.Windows, icon) | 122 | scene.Windows = append(scene.Windows, icon) | ||
| 132 | } | 123 | } | ||
| 124 | + | scene.Flash = c.flashFor(sc) | |||
| 133 | scene.CaptureIndicators = c.captureIndicators(sc) | 125 | scene.CaptureIndicators = c.captureIndicators(sc) | ||
| 134 | scene.Capture = c.captureSceneFor(sc, capture) | 126 | scene.Capture = c.captureSceneFor(sc, capture) | ||
| 135 | // The springs of this frame are advanced: a scene shown while one still | 127 | // The springs of this frame are advanced: a scene shown while one still | ||
| @@ -160,5 +152,5 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 160 | scene.CaptureScene = c.captureWindowScene(capture, scene.Seq) | 152 | scene.CaptureScene = c.captureWindowScene(capture, scene.Seq) | ||
| 161 | } | 153 | } | ||
| 162 | } | 154 | } | ||
| 163 | - | return scene, drawable, nil | 155 | + | return scene, nil |
| 164 | } | 156 | } | ||
M internal/core/scene_animating_test.go +9 -25
| @@ -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 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) | |
| 80 | 63 | } | |
| 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 | |
| 84 | 67 | } | |
| 68 | + | return out | |
| 85 | 69 | } | |
| 86 | 70 | ||
| 87 | 71 | // 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 the | 58 | + | // stepScenes steps only the screen and returns the scene of each output. |
| 59 | - | // focused scene settled, with a fresh Seq: otherwise outputs keep the | 59 | + | 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 { | |||
| 56 | 56 | // springing reports whether a camera or rect spring runs on the screen. | |
| 57 | 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 | 59 | // stopRects drops the rect motions: the windows are drawn where they are. | |
| 64 | 60 | func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | |
| 65 | 61 | ||
| @@ -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 +1 -1
| @@ -47,7 +47,7 @@ func (c *Core) syncSecurity() bool { | |||
| 47 | 47 | // protected epoch clears that focus. | |
| 48 | 48 | c.gesture = clientGesture{} | |
| 49 | 49 | c.stopAnimations() | |
| 50 | - | c.stopPulse() | |
| 50 | + | c.stopFlash() | |
| 51 | 51 | c.cancelSwitcher() | |
| 52 | 52 | c.stopRepeat() | |
| 53 | 53 | if state.Protected { | |
| @@ -47,7 +47,7 @@ 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() | 50 | + | c.stopFlash() |
| 51 | c.cancelSwitcher() | 51 | c.cancelSwitcher() | ||
| 52 | c.stopRepeat() | 52 | c.stopRepeat() | ||
| 53 | if state.Protected { | 53 | if state.Protected { | ||
M internal/core/transition.go +1 -2
| @@ -701,8 +701,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 701 | 701 | // while it shows on another) is animated on both screens: the destination's | |
| 702 | 702 | // windows slide in from where the source drew them (no workspace slide | |
| 703 | 703 | // 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. | |
| 706 | 705 | func (c *Core) transition(before []viewShot, now time.Time) { | |
| 707 | 706 | if !c.animOn() { | |
| 708 | 707 | 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's | 701 | // 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 slide | 702 | // 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 animated | 704 | + | // 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 | return | 707 | return | ||
M internal/core/viewport_test.go +4 -6
| @@ -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 the | |
| 188 | - | // focus pulse has nothing to show. | |
| 187 | + | // Panels that leave no room draw no tile, a leaving one included. | |
| 189 | 188 | func TestNoUsableAreaDrawsNoTile(t *testing.T) { | |
| 190 | 189 | c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{}) | |
| 191 | 190 | c.cur().mon.AddWindow(1) | |
| 192 | - | c.pulse.target = 1 | |
| 193 | 191 | 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") | |
| 196 | 194 | } | |
| 197 | 195 | sc := c.cur() | |
| 198 | 196 | sc.shown = slices.Clone(sc.shownLayout()) | |
| 199 | 197 | 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 | 199 | if err != nil { | |
| 202 | 200 | t.Fatal(err) | |
| 203 | 201 | } | |
| @@ -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 the | 187 | + | // 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 = true | 197 | 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 { | |||
| 89 | 89 | 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 | 92 | // Config is the parsed compositor configuration (see the config adapter for keys). | |
| 101 | 93 | type Config struct { | |
| 102 | 94 | Keyboard struct { | |
| @@ -148,7 +140,7 @@ type Config struct { | |||
| 148 | 140 | // motion; the next motion shows it. 0 never hides it. | |
| 149 | 141 | Cursor struct{ HideAfter time.Duration } | |
| 150 | 142 | // 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 | |
| 152 | 144 | // 10 (the config's animations.speed maps to it). | |
| 153 | 145 | Animations struct { | |
| 154 | 146 | On bool | |
| @@ -158,11 +150,16 @@ type Config struct { | |||
| 158 | 150 | // FollowMove shows the target workspace after a column or window | |
| 159 | 151 | // moves to it. | |
| 160 | 152 | 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 | |
| 166 | 163 | } | |
| 167 | 164 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 168 | 165 | Workspaces []WorkspaceConfig | |
| @@ -89,14 +89,6 @@ type OutputLayout struct { | |||||
| 89 | LayoutRules | 89 | 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 to | 143 | + | // 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 bool | 146 | On bool | ||
| @@ -158,11 +150,16 @@ type Config struct { | |||||
| 158 | // FollowMove shows the target workspace after a column or window | 150 | // FollowMove shows the target workspace after a column or window | ||
| 159 | // moves to it. | 151 | // moves to it. | ||
| 160 | FollowMove bool | 152 | 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 is | 154 | + | // (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 string | 156 | + | // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or |
| 165 | - | Strength float64 | 157 | + | // 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 []WorkspaceConfig | 165 | Workspaces []WorkspaceConfig | ||
M internal/ports/scene.go +18 -11
| @@ -69,7 +69,10 @@ type Scene struct { | |||
| 69 | 69 | // drawn over everything; nil when none. Captures never hold them: the | |
| 70 | 70 | // capture pipeline serves requests from a scene without them. | |
| 71 | 71 | 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 | |
| 73 | 76 | // this output after this frame was computed: the output reports the | |
| 74 | 77 | // flip of the scene to core (OutputFrame), which moves the animation to | |
| 75 | 78 | // the next frame. A scene of a settled output carries false, so a flip | |
| @@ -91,6 +94,9 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 91 | 94 | s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | |
| 92 | 95 | return false | |
| 93 | 96 | } | |
| 97 | + | if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) { | |
| 98 | + | return false | |
| 99 | + | } | |
| 94 | 100 | if s.CaptureScene != nil || o.CaptureScene != nil { | |
| 95 | 101 | return false | |
| 96 | 102 | } | |
| @@ -176,6 +182,17 @@ type SceneLayer struct { | |||
| 176 | 182 | Rect Rect | |
| 177 | 183 | } | |
| 178 | 184 | ||
| 185 | + | // FocusFlash is a brief inner border marking the entity an action just | |
| 186 | + | // changed: a window, a column or a whole output. Rect is in logical | |
| 187 | + | // pixels, Width the border width inside it, Color #rrggbb and Alpha its | |
| 188 | + | // opacity at this frame (0 to 1). | |
| 189 | + | type FocusFlash struct { | |
| 190 | + | Rect Rect | |
| 191 | + | Width int | |
| 192 | + | Color string | |
| 193 | + | Alpha float64 | |
| 194 | + | } | |
| 195 | + | ||
| 179 | 196 | // SceneWindow carries core → renderer window placement. | |
| 180 | 197 | type SceneWindow struct { | |
| 181 | 198 | ID WindowID | |
| @@ -195,16 +212,6 @@ type SceneWindow struct { | |||
| 195 | 212 | // or leaving animates it; a faded window is composed, never scanned | |
| 196 | 213 | // out. | |
| 197 | 214 | 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 | 215 | // Preview, above 0, draws the window's surfaces that much smaller in | |
| 209 | 216 | // Rect (an overview thumbnail): the client keeps its size. A preview | |
| 210 | 217 | // 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: the | 69 | // 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 []CaptureIndicator | 71 | 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 | |||
| 73 | // this output after this frame was computed: the output reports the | 76 | // this output after this frame was computed: the output reports the | ||
| 74 | // flip of the scene to core (OutputFrame), which moves the animation to | 77 | // 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 flip | 78 | // the next frame. A scene of a settled output carries false, so a flip | ||
| @@ -91,6 +94,9 @@ func (s Scene) SameAs(o Scene) bool { | |||||
| 91 | s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | 94 | s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | ||
| 92 | return false | 95 | return false | ||
| 93 | } | 96 | } | ||
| 97 | + | if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) { | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 94 | if s.CaptureScene != nil || o.CaptureScene != nil { | 100 | if s.CaptureScene != nil || o.CaptureScene != nil { | ||
| 95 | return false | 101 | return false | ||
| 96 | } | 102 | } | ||
| @@ -176,6 +182,17 @@ type SceneLayer struct { | |||||
| 176 | Rect Rect | 182 | Rect Rect | ||
| 177 | } | 183 | } | ||
| 178 | 184 | ||||
| 185 | + | // FocusFlash is a brief inner border marking the entity an action just | |||
| 186 | + | // changed: a window, a column or a whole output. Rect is in logical | |||
| 187 | + | // pixels, Width the border width inside it, Color #rrggbb and Alpha its | |||
| 188 | + | // opacity at this frame (0 to 1). | |||
| 189 | + | type FocusFlash struct { | |||
| 190 | + | Rect Rect | |||
| 191 | + | Width int | |||
| 192 | + | Color string | |||
| 193 | + | Alpha float64 | |||
| 194 | + | } | |||
| 195 | + | ||||
| 179 | // SceneWindow carries core → renderer window placement. | 196 | // SceneWindow carries core → renderer window placement. | ||
| 180 | type SceneWindow struct { | 197 | type SceneWindow struct { | ||
| 181 | ID WindowID | 198 | ID WindowID | ||
| @@ -195,16 +212,6 @@ type SceneWindow struct { | |||||
| 195 | // or leaving animates it; a faded window is composed, never scanned | 212 | // or leaving animates it; a faded window is composed, never scanned | ||
| 196 | // out. | 213 | // out. | ||
| 197 | Fade float64 | 214 | 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 in | 215 | // Preview, above 0, draws the window's surfaces that much smaller in | ||
| 209 | // Rect (an overview thumbnail): the client keeps its size. A preview | 216 | // Rect (an overview thumbnail): the client keeps its size. A preview | ||
| 210 | // is a card: it never opens the floats. | 217 | // is a card: it never opens the floats. | ||
M internal/ports/scene_same_test.go +8 -2
| @@ -23,6 +23,7 @@ func sameSceneBase() Scene { | |||
| 23 | 23 | Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}}, | |
| 24 | 24 | Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}}, | |
| 25 | 25 | ||
| 26 | + | Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6}, | |
| 26 | 27 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | |
| 27 | 28 | } | |
| 28 | 29 | } | |
| @@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) { | |||
| 36 | 37 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | |
| 37 | 38 | ||
| 38 | 39 | for name, edit := range map[string]func(*Scene){ | |
| 40 | + | "flash nil": func(s *Scene) { s.Flash = nil }, | |
| 41 | + | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | |
| 42 | + | "flash width": func(s *Scene) { s.Flash.Width++ }, | |
| 43 | + | "flash color": func(s *Scene) { s.Flash.Color = "#ffffff" }, | |
| 44 | + | "flash alpha": func(s *Scene) { s.Flash.Alpha = 0.2 }, | |
| 39 | 45 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | |
| 40 | 46 | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | |
| 41 | 47 | "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 }, | |
| @@ -83,9 +89,9 @@ func TestSceneSameAs(t *testing.T) { | |||
| 83 | 89 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | |
| 84 | 90 | // update Scene.SameAs, then this count. | |
| 85 | 91 | func TestSceneSameAsCoversEveryField(t *testing.T) { | |
| 86 | - | // 22 with Animating, which SameAs intentionally does not compare: it | |
| 92 | + | // 23 with Animating, which SameAs intentionally does not compare: it | |
| 87 | 93 | // only schedules the next frame, it draws nothing. | |
| 88 | - | const fields = 22 | |
| 94 | + | const fields = 23 | |
| 89 | 95 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 90 | 96 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 91 | 97 | } | |
| @@ -23,6 +23,7 @@ func sameSceneBase() Scene { | |||||
| 23 | Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}}, | 23 | Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}}, | ||
| 24 | Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}}, | 24 | Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}}, | ||
| 25 | 25 | ||||
| 26 | + | Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6}, | |||
| 26 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | 27 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | ||
| 27 | } | 28 | } | ||
| 28 | } | 29 | } | ||
| @@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) { | |||||
| 36 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | 37 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | ||
| 37 | 38 | ||||
| 38 | for name, edit := range map[string]func(*Scene){ | 39 | for name, edit := range map[string]func(*Scene){ | ||
| 40 | + | "flash nil": func(s *Scene) { s.Flash = nil }, | |||
| 41 | + | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | |||
| 42 | + | "flash width": func(s *Scene) { s.Flash.Width++ }, | |||
| 43 | + | "flash color": func(s *Scene) { s.Flash.Color = "#ffffff" }, | |||
| 44 | + | "flash alpha": func(s *Scene) { s.Flash.Alpha = 0.2 }, | |||
| 39 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | 45 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | ||
| 40 | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | 46 | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | ||
| 41 | "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 }, | 47 | "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 }, | ||
| @@ -83,9 +89,9 @@ func TestSceneSameAs(t *testing.T) { | |||||
| 83 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | 89 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | ||
| 84 | // update Scene.SameAs, then this count. | 90 | // update Scene.SameAs, then this count. | ||
| 85 | func TestSceneSameAsCoversEveryField(t *testing.T) { | 91 | func TestSceneSameAsCoversEveryField(t *testing.T) { | ||
| 86 | - | // 22 with Animating, which SameAs intentionally does not compare: it | 92 | + | // 23 with Animating, which SameAs intentionally does not compare: it |
| 87 | // only schedules the next frame, it draws nothing. | 93 | // only schedules the next frame, it draws nothing. | ||
| 88 | - | const fields = 22 | 94 | + | const fields = 23 |
| 89 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | 95 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | ||
| 90 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | 96 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | ||
| 91 | } | 97 | } | ||