Repository: holoviz/holoviews

Issue:
Easy specification of dimensions for hover tool

Instead of building an explicit hover tool to choose dimensions to show hover information for, maybe we could have a syntax such as:

```
tools=['hover+time|voltage']
```

Where ``+`` means you are building a whitelist (what to include) and ``-`` means it is a blacklist (what to exclude). E.g to exclude 'time' and 'voltage':

```
tools=['hover-time|voltage']
```



Discussion:
Not sure whether I'm convinced one way or another, but I would guess a flat syntax like 'hover+time+voltage-temperature' would be simpler to work with.
Definitely don't like using some special syntax inside a string like that. I'd be okay with a ``tooltips`` plot option, which accepts a list of dimensions.
What would ``tooltips`` do if there is no hover tool? Would it complain, silently ignore the issue or enable the hover tool if not already activated?

I don't thinks ``tooltips`` is the right name (these aren't tips for using tools) but maybe something like ``hoverdims`` or ``hoverinfo`` would be ok? Maybe just call the option ``hover``?
>What would tooltips do if there is no hover tool?

It would enable the hover tool.

>I don't thinks tooltips is the right name (these aren't tips for using tools) but maybe something like hoverdims or hoverinfo would be ok?

Tooltips is the right name imo, here's the definition of tooltips:

>a message which appears when a cursor is positioned over an icon, image, hyperlink, or other element in a graphical user interface.
![image](https://user-images.githubusercontent.com/890576/30035138-b7e939ca-919e-11e7-9e4a-a32405c3d6e2.png)

The key bit for me is the last point - it is normally about describing *function* and not giving data values. @jbednar's opinion might help us with a better suggestion and I don't mind ``tooltips`` too much if both of you don't think it is confusing.
``tooltips`` is also what bokeh calls it, so it would also be consistent.
That is a good argument for calling it ``tooltips`` then.
Don't ask me; I've always thought ``tooltips`` was a ridiculous and annoying name, and much prefer ``hover``.
I also like ``hover`` more than ``tooltips``. This might be one of those times where we decide we chose a better name than bokeh. :-)
So, what was the result of this? that sintaxis proposed in the OP works? 
thanks for your answers 👍 
We never have made progress on this issue, and it remains a major sticking point, particularly for hvPlot users, because the  Bokeh syntax for creating a custom HoverTool is miles away from the concise and convenient syntax that hvPlot and HoloViews users expect. 

Basically, you can have a nice, clean notebook about your work only if you don't care what shows up in the hover tool, i.e. if what it does by default is precisely what you wanted it to do. In my experience it's rare that what it shows by default is ideal, yet I still rarely modify that default in practice, because the syntax for specifying hover options is so verbose, awkward, and distracting from the story I am trying to tell in a given notebook.  Hover is useful, but it's rarely the main story, yet configuring it immediately takes over the notebook and becomes a complicated digression that I usually choose not to get into.

We don't necessarily need a DSL as proposed above, but I do think we should support some simple list syntax for specifying what fields to include in the hover without needing to import HoverTool.
I think a mini-DSL as proposed above fits in best with the existing plot options and would be the shortest, most concise way to do this. Unfortunately, specifying a hood hover tool is verbose and awkward as you point out and I can't think of a more concise (and backwards compatible) way to do this. I guess you could try something like:

```python
tools=[{'hover':['col1', 'col2']}, 'pan']
```

But that is hardly an improvement or any clearer imho.
I can't quite grok that syntax; seems like a tuple would work better than a dictionary there:

```python
tools=['pan', ('hover', ['col1', 'col2'])]
```
And to extend that to cover the full `tooltips` argument of the Bokeh HoverTool when needed:

```python
tools=['pan', ('hover',  dict(col1='@name',  col2='@symbol',  col3='$color[hex, swatch]:CPK')]
```
But just like @philippjfr above, I was imagining a separate option like `hover_cols=['col1','col2']` (or `tooltips`, if anyone likes that name!) and the equivalent dictionary version `hover_cols=dict(col1='@name',  col2='@symbol',  col3='$color[hex, swatch]:CPK')`.  Not sure whether the separate option or parsing `tools` is easier to implement, but either of these seems like a big improvement over importing HoverTool and configuring it.

That final suggestion seems pretty reasonable to me. The only think I would like is for there to be a clear mechanism that maps a string (e.g. 'pan', 'wheelzoom', 'hover') to a Bokeh tool model *or* the tuple format (e.g. `('hover', ['col1', 'col2'])` which should also support the HTML template). I'm not sure what would be customized for other tools but I would hope to make this mechanism general for all the tools...
Sounds like we could support a general mechanism for Bokeh tools where we generalize our `tools` parameter to accept tuples, i.e. something like `tools=['tool1', ('tool2', settings2), ('tool3', settings3)]`.  To implement that per tool, we'd register a handler for that tool that maps the given settings into configuration for that tool. For hover, what the handler would do is accept a string (i.e., HTML template), list (i.e., list of dimensions), or dict (i.e., list of dimension:formatter pairs) specification, and would map that into the `tooltips` argument of HoverTool. Other tools could be configured similarly. Something for you to attack, @jlstevens ?
Would be great to revisit this. still a major sticking point.

Especially if a user can also specify whether to include their 'label' and 'group' arg value as a hover/tooltip entry.. for instance: https://github.com/holoviz/holoviews/issues/3603
Wanted to drop some feature ideas:

- including/excluding columns (maybe new opts?) `hover_cols` or just a dict of list `tools=[{"hover": ["index", "x"]}]`
- formatting (dict of list): like https://github.com/holoviz/hvplot/issues/1095
```python
tools=[{"hover": ["{index.0f}", "{x:.1f}", {"y:.1f}"]}]
```
- renaming (dict of dict): https://github.com/holoviz/hvplot/issues/1258
```python
tools=[{"hover": {"index": "Index", "x": "X-Coordinate"}}
```
- renaming & formatting (dict of dict of tuple)?
```python
tools=[{"hover": {"index": ("Index", ".0f"), "x": ("X-Coordinate", ".1f")}}
```

- direct support bokeh tooltips kwarg directly (without having to import HoverTool(tooltips=...)` https://github.com/holoviz/holoviews/issues/1600, e.g.

```python
tools=[{"hover": {[
    ("index", "$index"),
    ("(x,y)", "(x=$x{0[.]00000}, y=$y{0[.]00000})"),
]}}]
```


Questions:
1. Since bokeh tooltip formatting is different from f-strings, I'm wondering how to reconcile that; do we support both, or just Bokeh style? Leaning towards just bokeh style to keep it simple
2. Should we go with nested dict/list/tuple or should we go for separate opts, e.g. (hover_format)
Over time, I have become even more hesitant to support these nested structures and I think I've come to the conclusion that we should leave `tools` alone and support `hover_X` options instead e.g:

```
hover_labels={"index": "Index", "x": "X-Coordinate"},
hover_format=["{index.0f}", "{x:.1f}", {"y:.1f}"]
```

I think that `hover_format` could support a dictionary to support the bokeh tooltip kwargs: this is still better than having to import the model from Bokeh (and of course, you can still supply the model directly in `tools` if you want).



@jlstevens could you expand on why you've become l hesitant to support the nested approach? 
These nested representations are often opaque, hard to manipulate correctly and brittle (more chance for syntax errors among other things).
Neither the nested nor the separate keywords are particularly discoverable ("how was I supposed to know there is a separate `hover_cols` keyword when I want to configure this tool?" vs "what on earth is that obscure syntax supposed to indicate"), so in the end I'm not sure I have much of an opinion other than that I just want _something_ providing control over this behavior without needing a separate tool and separate import. That way it can start working its way into our examples and people will be able to learn about either the separate keywords or about the fancy syntax.  So I'd say we should have a quick meeting about it, make a prototype, pass it around to see if it horrifies anyone, and go with it; it's better than dithering for more years!
A nested dict is indeed hard to work with, and since most people agreed that tile_opts would be the better option in https://github.com/holoviz/hvplot/pull/1299, I think I'll just start going with separate keywords like
```
hover_labels={"index": "Index", "x": "X-Coordinate"},
hover_format=["{index.0f}", "{x:.1f}", {"y:.1f}"]
```

However, I think both of these should be a list potentially? I'll have to try to get a better idea
I propose these three new params:

```python

    hover_tooltips = param.ClassSelector(class_=(str, list), default=[], doc="""
        A list of strings to be displayed in the hover tooltip.
        Or a list of tuples can be provided to also additionally specify the
        label and value format. Lastly, a string can be provided if a custom
        HTML template is provided.
        """)
    
    hover_formatters = param.List(default=[], doc="""
        A list of formatting options for the hover tooltips.""")
    
    hover_mode = param.ObjectSelector(default='mouse', objects=['mouse', 'vline', 'hline'], doc="""
        The hover mode determines how the hover tool is activated.""")
```

<img width="615" alt="image" src="https://github.com/holoviz/holoviews/assets/15331990/048e3d11-c5af-41d5-a993-9cd9b0967814">

To not deviate too much from Bokeh, it should mirror [what Bokeh has](https://docs.bokeh.org/en/latest/docs/user_guide/interaction/tools.html#formatting-tooltip-fields):
```python
hover_tooltips=[
        ( 'date',   '@date{%F}'            ),
        ( 'close',  '$@{adj close}{%0.2f}' ), # use @{ } for field names with spaces
        ( 'volume', '@volume{0.00 a}'      ),
    ],
hover_formatters={
        '@date'        : 'datetime', # use 'datetime' formatter for '@date' field
        '@{adj close}' : 'printf',   # use 'printf' formatter for '@{adj close}' field
                                     # use default 'numeral' formatter for other fields
},

    hover_mode='vline'

```

I believe this will be extremely convenient for users to have it broken up like this. Previously, users would have to import and completely build the `HoverTool` if they wanted to change one thing about the hover tooltips; now they just have to change one of these params.

I'd like to also propose supporting a list of strings in hover_tooltips:
```
hover_tooltips=["date", "close"]
```

And potentially a list of Python string formats(?, will decide if this is easy to implement; I think yes?)
```
hover_tooltips=["{date:%Y%m%d%H}", "{close:.02f}"]
```

In case users don't want to specify the format.
I like @ahuang11's suggestion: we can point to Bokeh's API and all we are doing is removing a level of nesting and an import when creating a hovertool (at least regarding the keywords and their basic functionality...we can also add some sugar if it is useful).
I'm +1 as well. Python string formats sound nice, but in practice may not be straightforward.

Resolving patch:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -230,6 +230,15 @@ class ElementPlot(BokehPlot, GenericElementPlot):
     tools = param.List(default=[], doc="""
         A list of plugin tools to use on the plot.""")
 
+    hover_tooltips = param.ClassSelector(class_=(list, str), doc="""
+        A list of dimensions to be displayed in the hover tooltip.""")
+
+    hover_formatters = param.Dict(doc="""
+        A dict of formatting options for the hover tooltip.""")
+
+    hover_mode = param.ObjectSelector(default='mouse', objects=['mouse', 'vline', 'hline'], doc="""
+        The hover mode determines how the hover tool is activated.""")
+
     toolbar = param.ObjectSelector(default='right',
                                    objects=["above", "below",
                                             "left", "right", "disable", None],
@@ -286,16 +295,139 @@ def _hover_opts(self, element):
         dims += element.dimensions()
         return list(util.unique_iterator(dims)), {}
 
+    def _replace_hover_label_group(self, element, tooltip):
+        if isinstance(tooltip, tuple):
+            has_label = hasattr(element, 'label') and element.label
+            has_group = hasattr(element, 'group') and element.group != element.param.group.default
+            if not has_label and not has_group:
+                return tooltip
+
+            if ("$label" in tooltip or "${label}" in tooltip):
+                tooltip = (tooltip[0], element.label)
+            elif ("$group" in tooltip or "${group}" in tooltip):
+                tooltip = (tooltip[0], element.group)
+        elif isinstance(tooltip, str):
+            if "$label" in tooltip:
+                tooltip = tooltip.replace("$label", element.label)
+            elif "${label}" in tooltip:
+                tooltip = tooltip.replace("${label}", element.label)
+
+            if "$group" in tooltip:
+                tooltip = tooltip.replace("$group", element.group)
+            elif "${group}" in tooltip:
+                tooltip = tooltip.replace("${group}", element.group)
+        return tooltip
+
+    def _replace_hover_value_aliases(self, tooltip, tooltips_dict):
+        for name, tuple_ in tooltips_dict.items():
+            # some elements, like image, rename the tooltip, e.g. @y -> $y
+            # let's replace those, so the hover tooltip is discoverable
+            # ensure it works for `(@x, @y)` -> `($x, $y)` too
+            if isinstance(tooltip, tuple):
+                value_alias = tuple_[1]
+                if f"@{name}" in tooltip[1]:
+                    tooltip = (tooltip[0], tooltip[1].replace(f"@{name}", value_alias))
+                elif f"@{{{name}}}" in tooltip[1]:
+                    tooltip = (tooltip[0], tooltip[1].replace(f"@{{{name}}}", value_alias))
+            elif isinstance(tooltip, str):
+                if f"@{name}" in tooltip:
+                    tooltip = tooltip.replace(f"@{name}", tuple_[1])
+                elif f"@{{{name}}}" in tooltip:
+                    tooltip = tooltip.replace(f"@{{{name}}}", tuple_[1])
+        return tooltip
+
+    def _prepare_hover_kwargs(self, element):
+        tooltips, hover_opts = self._hover_opts(element)
+
+        dim_aliases = {
+            f"{dim.label} ({dim.unit})" if dim.unit else dim.label: dim.name
+            for dim in element.kdims + element.vdims
+        }
+
+        # make dict so it's easy to get the tooltip for a given dimension;
+        tooltips_dict = {}
+        units_dict = {}
+        for ttp in tooltips:
+            if isinstance(ttp, tuple):
+                name = ttp[0]
+                tuple_ = (ttp[0], ttp[1])
+            elif isinstance(ttp, Dimension):
+                name = ttp.name
+                # three brackets means replacing variable,
+                # and then wrapping in brackets, like @{air}
+                unit = f" ({ttp.unit})" if ttp.unit else ""
+                tuple_ = (
+                    ttp.pprint_label,
+                    f"@{{{util.dimension_sanitizer(ttp.name)}}}"
+                )
+                units_dict[name] = unit
+            elif isinstance(ttp, str):
+                name = ttp
+                # three brackets means replacing variable,
+                # and then wrapping in brackets, like @{air}
+                tuple_ = (ttp.name, f"@{{{util.dimension_sanitizer(ttp)}}}")
+
+            if name in dim_aliases:
+                name = dim_aliases[name]
+
+            # key is the vanilla data column/dimension name
+            # value should always be a tuple (label, value)
+            tooltips_dict[name] = tuple_
+
+        # subset the tooltips to only the ones user wants
+        if self.hover_tooltips:
+            # If hover tooltips are defined as a list of strings or tuples
+            if isinstance(self.hover_tooltips, list):
+                new_tooltips = []
+                for tooltip in self.hover_tooltips:
+                    if isinstance(tooltip, str):
+                        # make into a tuple
+                        new_tooltip = tooltips_dict.get(tooltip.lstrip("@"))
+                        if new_tooltip is None:
+                            label = tooltip.lstrip("$").lstrip("@")
+                            value = tooltip if "$" in tooltip else f"@{{{tooltip.lstrip('@')}}}"
+                            new_tooltip = (label, value)
+                        new_tooltips.append(new_tooltip)
+                    elif isinstance(tooltip, tuple):
+                        unit = units_dict.get(tooltip[0])
+                        tooltip = self._replace_hover_value_aliases(tooltip, tooltips_dict)
+                        if unit:
+                            tooltip = (f"{tooltip[0]}{unit}", tooltip[1])
+                        new_tooltips.append(tooltip)
+                    else:
+                        raise ValueError('Hover tooltips must be a list with items of strings or tuples.')
+                tooltips = new_tooltips
+            else:
+                # Likely HTML str
+                tooltips = self._replace_hover_value_aliases(self.hover_tooltips, tooltips_dict)
+        else:
+            tooltips = list(tooltips_dict.values())
+
+        # replace the label and group in the tooltips
+        if isinstance(tooltips, list):
+            tooltips = [self._replace_hover_label_group(element, ttp) for ttp in tooltips]
+        elif isinstance(tooltips, str):
+            tooltips = self._replace_hover_label_group(element, tooltips)
+
+        if self.hover_formatters:
+            hover_opts['formatters'] = self.hover_formatters
+
+        if self.hover_mode:
+            hover_opts["mode"] = self.hover_mode
+
+        return tooltips, hover_opts
+
     def _init_tools(self, element, callbacks=None):
         """
         Processes the list of tools to be supplied to the plot.
         """
         if callbacks is None:
             callbacks = []
-        tooltips, hover_opts = self._hover_opts(element)
-        tooltips = [(ttp.pprint_label, '@{%s}' % util.dimension_sanitizer(ttp.name))
-                    if isinstance(ttp, Dimension) else ttp for ttp in tooltips]
-        if not tooltips: tooltips = None
+
+        tooltips, hover_opts = self._prepare_hover_kwargs(element)
+
+        if not tooltips:
+            tooltips = None
 
         callbacks = callbacks+self.callbacks
         cb_tools, tool_names = [], []
@@ -314,13 +446,23 @@ def _init_tools(self, element, callbacks=None):
                     cb_tools.append(tool)
                     self.handles[handle] = tool
 
+        all_tools = cb_tools + self.default_tools + self.tools
+        if self.hover_tooltips:
+            no_hover = (
+                "hover" not in all_tools and
+                not (any(isinstance(tool, tools.HoverTool) for tool in all_tools))
+            )
+            if no_hover:
+                all_tools.append("hover")
+
         tool_list = []
-        for tool in cb_tools + self.default_tools + self.tools:
+        for tool in all_tools:
             if tool in tool_names:
                 continue
             if tool in ['vline', 'hline']:
+                tool_opts = dict(hover_opts, mode=tool)
                 tool = tools.HoverTool(
-                    tooltips=tooltips, tags=['hv_created'], mode=tool, **hover_opts
+                    tooltips=tooltips, tags=['hv_created'], **tool_opts
                 )
             elif bokeh32 and isinstance(tool, str) and tool.endswith(
                 ('wheel_zoom', 'zoom_in', 'zoom_out')
@@ -392,9 +534,7 @@ def _init_tools(self, element, callbacks=None):
     def _update_hover(self, element):
         tool = self.handles['hover']
         if 'hv_created' in tool.tags:
-            tooltips, hover_opts = self._hover_opts(element)
-            tooltips = [(ttp.pprint_label, '@{%s}' % util.dimension_sanitizer(ttp.name))
-                        if isinstance(ttp, Dimension) else ttp for ttp in tooltips]
+            tooltips, hover_opts = self._prepare_hover_kwargs(element)
             tool.tooltips = tooltips
         else:
             plot_opts = element.opts.get('plot', 'bokeh')


Test patch:
diff --git a/holoviews/tests/ui/bokeh/test_hover.py b/holoviews/tests/ui/bokeh/test_hover.py
new file mode 100644
--- /dev/null
+++ b/holoviews/tests/ui/bokeh/test_hover.py
@@ -0,0 +1,234 @@
+import time
+
+import numpy as np
+import pytest
+
+import holoviews as hv
+
+from .. import expect, wait_until
+
+pytestmark = pytest.mark.ui
+
+
+def delay_rerun(*args):
+    time.sleep(2)
+    return True
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_list(serve_hv):
+    hv_image = hv.Image(
+        np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+    ).opts(hover_tooltips=["$x", "xc", "@yc", "@z"])
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("x:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("xc:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("yc:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("z:")
+    expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_unit_format(serve_hv):
+    dim = hv.Dimension("Test", unit="Unit")
+    hv_image = hv.Image(
+        np.zeros((10, 10)), bounds=(0, 0, 1, 1), kdims=["xc", "yc"], vdims=[dim]
+    ).opts(hover_tooltips=[("Test", "@Test{%0.2f}")])
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("Test: 0.00%")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_list_mix_tuple_string(serve_hv):
+    hv_image = hv.Image(
+        np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+    ).opts(hover_tooltips=[("xs", "($x, @xc)"), "yc", "z"])
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("xs:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("yc:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("z:")
+    expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_label_group(serve_hv):
+    hv_image = hv.Image(
+        np.random.rand(10, 10),
+        bounds=(0, 0, 1, 1),
+        kdims=["xc", "yc"],
+        label="Image Label",
+        group="Image Group",
+    ).opts(
+        hover_tooltips=[
+            "$label",
+            "$group",
+            ("Plot Label", "$label"),
+            ("Plot Group", "$group"),
+        ]
+    )
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("label:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("group:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("Plot Label:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("Plot Group:")
+    expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_missing(serve_hv):
+    hv_image = hv.Image(
+        np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+    ).opts(hover_tooltips=["abc"])
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_html_string(serve_hv):
+    hv_image = hv.Image(
+        np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+    ).opts(hover_tooltips="<b>x</b>: $x<br>y: @yc")
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("x:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("y:")
+    expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_hover_tooltips_formatters(serve_hv):
+    hv_image = hv.Image(
+        np.random.rand(10, 10), bounds=(0, 0, 1, 1), kdims=["xc", "yc"]
+    ).opts(
+        hover_tooltips=[("X", "($x, @xc{%0.3f})")], hover_formatters={"@xc": "printf"}
+    )
+
+    page = serve_hv(hv_image)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("X:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("%")
+    expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize("hover_mode", ["hline", "vline"])
+def test_hover_mode(serve_hv, hover_mode):
+    hv_curve = hv.Curve([0, 10, 2]).opts(tools=["hover"], hover_mode=hover_mode)
+
+    page = serve_hv(hv_curve)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("x:")
+    expect(page.locator(".bk-Tooltip")).to_contain_text("y:")
+    expect(page.locator(".bk-Tooltip")).not_to_contain_text("?")
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize(
+    "hover_tooltip",
+    [
+        "Amplitude",
+        "@Amplitude",
+        ("Amplitude", "@Amplitude"),
+    ],
+)
+def test_hover_tooltips_dimension_unit(serve_hv, hover_tooltip):
+    amplitude_dim = hv.Dimension("Amplitude", unit="µV")
+    hv_curve = hv.Curve([0, 10, 2], vdims=[amplitude_dim]).opts(
+        hover_tooltips=[hover_tooltip], hover_mode="vline"
+    )
+
+    page = serve_hv(hv_curve)
+    hv_plot = page.locator(".bk-events")
+    wait_until(lambda: expect(hv_plot).to_have_count(1), page=page)
+    bbox = hv_plot.bounding_box()
+
+    # Hover over the plot
+    page.mouse.move(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2)
+    page.mouse.up()
+
+    wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page)
+
+    expect(page.locator(".bk-Tooltip")).to_contain_text("Amplitude (µV): 10")