Repository: vega/altair

Issue:
`Parameter.empty` not preserved with `&`, `|`, `~` operators
### What happened?

The following code works as expected:

```py
import altair as alt
from altair.datasets import data

click = alt.selection_point(empty=False)
ctrl_click = alt.selection_point(on='click[event.ctrlKey]', empty=False)

points = alt.Chart(data.cars.url).mark_point().encode(
    x='Horsepower:Q',
    y='Miles_per_Gallon:Q',
    fill=alt.condition(ctrl_click, alt.value('red'), alt.value('white')),
    size=alt.condition(click, alt.value(1000), alt.value(50))
).add_params(
    click, ctrl_click
)

points
```

![image](https://github.com/user-attachments/assets/12203e32-a582-4530-b040-9b001542dbb7)

[Open the Chart in the Vega Editor](https://vega.github.io/editor/#/url/vega-lite/N4Igxg9gdgZglgcxALlANzgUwO4tJKAFzigFcJSBnAdTgBNCALFAZgAY2AacaYsiygAlMiRoVYcAvpO50AhoTl4QpAE4AbFCDGEADpWQB6Q2DpQAdACtKdTOrhpV5qJkKGougLaG0mBHIBaeUVKV0oAATQARnMAJgBOczZDYLkTOVVKK0poEBkQTwyAa2VCAE9dTC1dCBJxfMwoSDoSJFQQeHVNdoIW4lz23Qy5T2rhzwB9WLYANhBuNDl1UirkEFVMOnmQTE9dcpQYJdD8xeXVkGxGOEIq-Mo4AC9V-Gg+uAHQIdURsZ-J6YAVm2ZxWKCiHC4Oz2B2QR3UJwWSzByEBbHyAA9lPA7Fs1oIIJlMDVsJhVNtypUtABHUhyIg3BQOO7cMrYrDqPEgACycHUmEoE0qqgmAHElupctxKRdafTiIpiL48vlviNKCgANqgKAjC5qgFsYHcUL8sDidoy6q1Igqzg6vV-EZTWbbU2Yc2lCr6m3ibgDcD2MBFTWYXxEczmjQAaUwZQAuir49wACSUMCMXZKNY6fRGHx+OTmBA3RikABG5g+hnTmcKBf8AXstx8gLibHMMWsuUkQA)

However, when changing the fill condition to include a parameter composition:

```py
fill=alt.condition(ctrl_click & click, alt.value('red'), alt.value('white')),
```

The value `empty=False` is no longer respected for that encoding channel:

![image](https://github.com/user-attachments/assets/faff791f-cb09-4af7-8f86-fcf701d432d3)

[Open the Chart in the Vega Editor](https://vega.github.io/editor/#/url/vega-lite/N4Igxg9gdgZglgcxALlANzgUwO4tJKAFzigFcJSBnAdTgBNCALFAZgAY2AacaYsiygAlMiRoVYcAvpO50AhoTl4QpAE4AbFCDGEADpWQB6Q2DpQAdACtKdTOrhpV5qJkKGougLaG0mBHIBaeUVKV0oAATQARnMAJgBOczZDYLkTOVVKK0poEBkQTwyAa2VCAE9dTC1dCBJxfMwoSDoSJFQQeHVNdoIW4lz2wkxKcXa5KDoUAG1QXQy5T2r5zwB9WLYADjzOWeWl1QW1tgB2PIBdfLQ5dVIq5BBVTEnL69utbEY4Ie2QSjgALzu+GgfTgA12B0W9zmkKOp24VxudyiHC4IEwnl05RQMGuoReSJQAFY2PkAB7KeB2Sb3QQQTKYGrYTCqEDccqVLQAR1I42IimIvh+ZUpWHUNJAAFk4OphitKqoVgBxa7qXLsip3EA8vlfBQOKrSbgwhaUaagKALLUm1breG-OyYMCjUAc621Ih5GQWq37Q7rLbcUKy52lTXVD3ibgDcD2MBFKaYXxEczOjQAaUwZTOXrO3AAJJQwIwMUp7jp9EYfH45OYEF9GKQAEbmMGGIslwrV-wBexDHxEuJscwxay5SRAA)

In the VL spec you can see that this happens because Altair converts the parameter composition into the following VL:

```json
"condition": {
  "test": {"and": [{"param": "param_208"}, {"param": "param_207"}]},
  "value": "red"
},
```


### What would you like to happen instead?

The correct conversion to VL would look like this:

```json
"condition": {
  "test": {"and": [{"param": "param_208", "empty": false}, {"param": "param_207", "empty": false}]},
  "value": "red"
},
```

Not sure how long this has been present, but as I show above it is not related to the resent introduction of `when` since it also happens with `condition`.

# Minimal Repro

Composition operators [only preserve `Parameter.name`](https://github.com/vega/altair/issues/3598#issuecomment-2363699531)
```py
import altair as alt

click = alt.selection_point("click", empty=False)
ctrl_click = alt.selection_point("ctrl_click", on="click[event.ctrlKey]", empty=True)

>>> (click & ctrl_click).to_dict()
{'and': [{'param': 'click'}, {'param': 'ctrl_click'}]}

ctrl_click_false = alt.selection_point("ctrl_click_false", on="click[event.ctrlKey]", empty=False)

>>> (ctrl_click_false & ctrl_click).to_dict()
{'and': [{'param': 'ctrl_click_false'}, {'param': 'ctrl_click'}]}
```

### Which version of Altair are you using?

- `5.4.1`
- `5.6.0dev` (https://github.com/vega/altair/commit/17baaa23175bf15b57f73b48c1a0e855e3a5b3c7)


Discussion:
This is unrelated to both `condition` and `when`.

All of these `Parameter` methods use `Parameter.name` only
- omitting `Parameter.(empty|param|param_type)`:

https://github.com/vega/altair/blob/62cd37774cc1abfe2905b2ae554c75ee33a65907/altair/vegalite/v5/api.py#L391-L421

This **should** be preserved for `param_type=="selection"` https://vega.github.io/vega-lite/docs/predicate.html#selection-predicate - but appears to never have been in `altair`.



Patch:
diff --git a/altair/vegalite/v6/api.py b/altair/vegalite/v6/api.py
index 433da21a6..7c505cb83 100644
--- a/altair/vegalite/v6/api.py
+++ b/altair/vegalite/v6/api.py
@@ -410,23 +410,38 @@ def to_dict(self) -> dict[str, str | dict[str, Any]]:
 
     def __invert__(self) -> PredicateComposition | Any:
         if self.param_type == "selection":
-            return core.PredicateComposition({"not": {"param": self.name}})
+            param_dict: dict[str, str | bool] = {"param": self.name}
+            if isinstance(self.empty, bool):
+                param_dict["empty"] = self.empty
+            return core.PredicateComposition({"not": param_dict})
         else:
             return _expr_core.OperatorMixin.__invert__(self)
 
     def __and__(self, other: Any) -> PredicateComposition | Any:
         if self.param_type == "selection":
+            self_dict: dict[str, str | bool] = {"param": self.name}
+            if isinstance(self.empty, bool):
+                self_dict["empty"] = self.empty
             if isinstance(other, Parameter):
-                other = {"param": other.name}
-            return core.PredicateComposition({"and": [{"param": self.name}, other]})
+                other_dict: dict[str, str | bool] = {"param": other.name}
+                if isinstance(other.empty, bool):
+                    other_dict["empty"] = other.empty
+                other = other_dict
+            return core.PredicateComposition({"and": [self_dict, other]})
         else:
             return _expr_core.OperatorMixin.__and__(self, other)
 
     def __or__(self, other: Any) -> PredicateComposition | Any:
         if self.param_type == "selection":
+            self_dict: dict[str, str | bool] = {"param": self.name}
+            if isinstance(self.empty, bool):
+                self_dict["empty"] = self.empty
             if isinstance(other, Parameter):
-                other = {"param": other.name}
-            return core.PredicateComposition({"or": [{"param": self.name}, other]})
+                other_dict: dict[str, str | bool] = {"param": other.name}
+                if isinstance(other.empty, bool):
+                    other_dict["empty"] = other.empty
+                other = other_dict
+            return core.PredicateComposition({"or": [self_dict, other]})
         else:
             return _expr_core.OperatorMixin.__or__(self, other)
 


Test patch:
diff --git a/tests/vegalite/v6/test_api.py b/tests/vegalite/v6/test_api.py
index 40dd7c050..f03c42902 100644
--- a/tests/vegalite/v6/test_api.py
+++ b/tests/vegalite/v6/test_api.py
@@ -1027,6 +1027,91 @@ def test_selection():
     assert len(names) == 3
 
 
+def test_selection_empty_property_preservation():
+    """Test that the empty property is preserved in logical operations on selections."""
+    # Test basic selection with empty=False
+    click = alt.selection_point("click", empty=False)
+    ctrl_click = alt.selection_point(
+        name="ctrl_click", on="click[event.ctrlKey]", empty=True
+    )
+
+    # Test AND operation preserves empty properties
+    and_result = (click & ctrl_click).to_dict()
+    expected_and = {
+        "and": [
+            {"param": "click", "empty": False},
+            {"param": "ctrl_click", "empty": True},
+        ]
+    }
+    assert and_result == expected_and
+
+    # Test with both selections having empty=False
+    ctrl_click_false = alt.selection_point(
+        name="ctrl_click_false", on="click[event.ctrlKey]", empty=False
+    )
+    and_result_false = (ctrl_click_false & ctrl_click).to_dict()
+    expected_and_false = {
+        "and": [
+            {"param": "ctrl_click_false", "empty": False},
+            {"param": "ctrl_click", "empty": True},
+        ]
+    }
+    assert and_result_false == expected_and_false
+
+    # Test OR operation preserves empty properties
+    or_result = (click | ctrl_click).to_dict()
+    expected_or = {
+        "or": [
+            {"param": "click", "empty": False},
+            {"param": "ctrl_click", "empty": True},
+        ]
+    }
+    assert or_result == expected_or
+
+    # Test NOT operation preserves empty property
+    not_result = (~click).to_dict()
+    expected_not = {"not": {"param": "click", "empty": False}}
+    assert not_result == expected_not
+
+    # Test complex nested operations
+    complex_result = ((click & ctrl_click) | (~ctrl_click_false)).to_dict()
+    expected_complex = {
+        "or": [
+            {
+                "and": [
+                    {"param": "click", "empty": False},
+                    {"param": "ctrl_click", "empty": True},
+                ]
+            },
+            {"not": {"param": "ctrl_click_false", "empty": False}},
+        ]
+    }
+    assert complex_result == expected_complex
+
+    # Test with interval selections
+    interval_false = alt.selection_interval(name="interval_false", empty=False)
+    interval_true = alt.selection_interval(name="interval_true", empty=True)
+
+    interval_and = (interval_false & interval_true).to_dict()
+    expected_interval_and = {
+        "and": [
+            {"param": "interval_false", "empty": False},
+            {"param": "interval_true", "empty": True},
+        ]
+    }
+    assert interval_and == expected_interval_and
+
+    # Test mixed selection types
+    mixed_result = (click & interval_true).to_dict()
+    expected_mixed = {
+        "and": [
+            {"param": "click", "empty": False},
+            {"param": "interval_true", "empty": True},
+        ]
+    }
+    assert mixed_result == expected_mixed
+
+
 def test_transforms():
     # aggregate transform
     agg1 = alt.AggregatedFieldDef(op="mean", field="y", **{"as": "x1"})