Easy-to-use declarative tooltips
I am currently trying to add tooltips to my application. By tooltip I mean a small box of text that appears when the users hovers over widgets for a few seconds that provides additional information/explanation of the setting modeled by the widget. The tooltip appears without a click and disappears when the user moves the mouse away from the widget (but not while he is still on the widget).
AFAICS the documentation recommends using a PopupWindow. AFAICS it has the following disadvantages:
- a
PopupWindowmust be explicitly activated usingshow(). When I want it to appear on hover (without a click), the only way is to add aTouchAreaand a rather complicated
pointer-event(event) => {
if (event.kind == PointerEventKind.move) {
popup.show();
}
- in some cases a
PopupWindowbecomes a separate window, with its own window decorators, that needs to be closed manually - the position of the
PopupWindowmust be set manually - the size of the
PopupWindowmust be set manually - surrounding components with
TouchAreacan break the layout, e.g. when using it inside aRow
What I would prefer is to have
- a property
tooltip : "This is some additional information;that can be added to most components including layouts such asHorizontalLayoutor - a component
Tooltip { text : "This is some additional information; delay: 1s;}that can be a child of most components.
Slint would then display this tooltip whenever the user hovers over the area of the component, whether it is a button or a whole area. Slint would also take care of the size and position of the tooltip. It would adjust the size to be just big enough to contain the text and would position the tooltip such that it is close to the cursor, but out of its way and still inside the window. The background of the tooltip would be different enough for it to pop out visually.
Is anything like this planned?
Yes, a Tooltip { text : "This is some additional information; } seems like something we'd like to have in slint.
I wlll probably use this workaround. Would be interesting if anyone has a more elegant/shorter solution using the current feature set (Slint 1.8.0).
myTouchArea := TouchArea {
Button {
text: "Press me!"
}
}
Rectangle {
background: black;
x: 20px; y: 20px; height: 80px; width: 120px;
opacity: myTouchArea.has-hover ? 1 : 0;
border-radius: 5px;
Text {
text: "If you press this button, something cool will happen!";
padding: 10px;
color: white;
}
}
This has the mentioned disadvantages of being a lot of code and having to manually set the position and size of the Rectangle.
While we wait for a clean upstream implementation, I build a reusable ToolTip component. Maybe this is useful to other people.
export component ToolTip inherits Window {
in property <string> text <=> text.text;
in property <bool> user_is_hovering;
property <bool> has_waited;
z: 100;
Rectangle {
visible: has_waited && parent.user_is_hovering;
background: Palette.foreground;
border-radius: 0.5rem;
text := Text {
width: parent.width - 1rem;
height: parent.height - 1rem;
color: Palette.background;
font-size: root.default-font-size * 0.9;
horizontal-alignment: left;
vertical-alignment: top;
wrap: word-wrap;
}
}
Timer {
// Delay until tooltip appears.
interval: 1s;
running: parent.user_is_hovering || self.needs_resetting;
// The resetting property allows the timer to reset to the default state
// where it has not waited to show the tooltip yet. This is needed
// because we can not listen to a "not hovering anymore" event. Thus, we
// let the timer run a little bit longer and reset the state before
// stopping `running`.
property <bool> needs_resetting : false;
triggered() => {
if needs_resetting && !parent.user_is_hovering {
// User is not hovering anymore.
parent.has_waited = false;
needs_resetting = false;
}
else {
// User is hovering and waited, so tooltip can be shown.
parent.has_waited = true;
needs_resetting = true;
}
}
}
}
You can use is as follows
// Outside of any layouts define as many tooltups as you want like this.
crfToolTip := ToolTip {
x: crfTouch.x + 50px; y: crfTouch.y + 140px; // <-- references any or no component, not necessarily a TouchArea
height: 10rem;
width: 25rem;
text: "The constant rate factor (CRF) determines the quality of the resulting video stream. The "
+ "lower the CRF, the more bits AV1 will use to encode the video stream. A lower "
+ "CRF will lead to better quality but also a larger file. Thus, here you decide between quality and size.";
user_is_hovering: crfTouch.has-hover; // <-- references one or more TouchArea below
// To show this tooltip for several TouchAreas:
// user_is_hovering: crfTouch.has-hover || yourOtherTouchArea.has-hover;
}
// You can put one or more components in a TouchArea. The tooltip will
// activate on all components in the TouchArea. The TouchArea is needed
// as a container, since this is the only component that has a `has-hover` property.
crfTouch := TouchArea {
HorizontalLayout {
Text {
text: "CRF";
}
Slider {
min-width: 100px;
minimum: 0;
maximum: 63;
}
}
}
For each tooltip you need to manually set the coordinates (x, y) and the size (height, width). I didn't find a function in Slint that gives me the length of a text property, so I don't think you can calculate this automatically. You could do this in Rust, but I didn't want to define tooltips there.
This is how it looks (Here UI is more complex than in above example)
Thanks @PanCakeConnaisseur for this example!
(for reference, there was also some discussion about tooltip before in https://github.com/slint-ui/slint/discussions/1617)
@ogoffart Thank you for the link. Your solution using states and the mouse position is much cleaner than using a Timer and hardcoded positions. It introduces two problems though, that I wasn't able to solve yet.
- The
Rectangletooltip only covers components from its ownTouchAreaby default. - There is no easy way to get the main window's width.
I combined mine and your solution and came up with
component ToolTipArea inherits Window {
preferred-height: 100%;
preferred-width: 100%;
in property <string> text;
in property <length> window-width;
Rectangle {
states [
visible when ta.has-hover: {
opacity: 1;
in {
animate opacity { duration: 175ms; delay: 1000ms; }
}
}
]
x: max(-parent.absolute-position.x, min(ta.mouse-x - 2rem, - parent.absolute-position.x + window-width - self.width));
y: ta.mouse-y + 2rem;
background: Palette.foreground;
border-radius: 0.4rem;
opacity: 0;
width: hlayout.preferred-width;
height: hlayout.preferred-height;
hlayout := HorizontalLayout {
padding: 0.6rem;
Text {
text <=> root.text;
wrap:word-wrap;
width: 15rem;
color: Palette.background;
font-size: root.default-font-size * 0.9;
}
}
}
ta := TouchArea {
@children
}
}
// Use later
ToolTipArea {
text : "Don't reencode audio stream(s) using Opus, but copy them as is.";
window-width: root.width; // <-- assuming that root is the main window component. avoid this?
copyAudio := Switch {
z:-7; // <-- avoid this?
text: "Copy original streams";
toggled => {
root.copy_toggled(self.checked);
}
}
}
The problems in detail
1. Rectangle's z
The tooltip rectangle does not cover elements that are outside of the same ToolTipArea (TouchArea).
I can solve it partly by setting the
z value of TouchAreas below to a lower value.
ToolTipArea {
z: -1;
// content
}
ToolTipArea {
z: -2;
// content
}
ToolTipArea {
z: -3;
// content
//...
}
But setting a low z to e.g. a HorizontalLayout or a Switch seems to have no effect, it is still drawn above the tooltip. And the workaround only works as long as each tooltip covers only elements below it. Is there a better solution for this? AFAIK z must be determined at compile time but I would like to set something that makes the tooltip be on top of everything else in the entire window while it is shown.
2. Main Window Width
I would like to keep the tooltip always inside the window. I created an expression for the Rectangle's x position but it needs the width of the main window. AFAICS there is no direct way to get this value. I had to define the property window-width and pass it to each ToolTipArea component, which is quite cumbersome. Should I open a new issue for this as a feature request or is it already implemented?
I'd like to share a tooltip implementation base on yours. The core idea is: let the tooltip outside any layout, so it could appears anywhere, without affect the UI layout.
export component ToolTipArea {
preferred-height: 100%;
preferred-width: 100%;
property <string> text;
property <bool> has-hover;
property <length> mouse-x;
property <length> mouse-y;
public function show-tooltip(has-hover: bool, x: length, y: length, msg: string) {
if !has-hover {
root.has-hover = false;
return;
}
root.has-hover = has-hover;
root.text = msg;
// if tooltip is outside of the window
if x + 1rem + container.width > self.width - 10px {
root.mouse-x = self.width - container.width - 10px;
} else {
root.mouse-x = x + 1rem;
}
if y + 1rem + container.height > self.height - 10px {
root.mouse-y = y - 1rem - container.height;
} else {
root.mouse-y = y + 1rem;
}
}
container := Rectangle {
states [
visible when root.has-hover: {
opacity: 0.8;
in {
animate opacity { duration: 175ms; delay: 700ms; }
}
}
]
x: root.mouse-x;
y: root.mouse-y;
background: Skin.palette.shadow;
opacity: 0;
width: tt_l.preferred-width;
height: tt_l.preferred-height;
tt_l := HorizontalLayout {
padding: 3px;
Text {
text <=> root.text;
wrap: word-wrap;
max-width: 15rem;
}
}
}
}
Then use it like:
export component MyWindow inherits Window {
VerticalBox {
Rectangle {width: 300px; height: 100px;}
// ... your UI
HorizontalBox {
Button { text: "show the tooltip"; }
TouchArea {
width: 14px;
height: 14px;
Rectangle {
border-radius: 5px;
background: #333;
}
changed has-hover => {
tooltip.show-tooltip(self.has-hover, self.absolute-position.x + self.mouse-x, self.absolute-position.y + self.mouse-y, "Just for test");
}
}
Rectangle {}
}
Rectangle { height: 50px;}
HorizontalBox {
Button { text: "another tooltip"; }
TouchArea {
width: 14px;
height: 14px;
Rectangle {
border-radius: 5px;
background: #333;
}
changed has-hover => {
tooltip.show-tooltip(self.has-hover, self.absolute-position.x + self.mouse-x, self.absolute-position.y + self.mouse-y, "Another test");
}
}
Rectangle {}
}
// ... your UI
Rectangle {width: 300px; height: 100px;}
}
tooltip := ToolTipArea { }
}
While the tooltip is not inside any layout, and you can't see two tooltips at the same time, you could use two TouchArea to trigger one tooltip.
- Rectangle's z The tooltip rectangle does not cover elements that are outside of the same ToolTipArea (TouchArea).
Just to mention it... I don't thing extending the Tooltip to cover other elements is enough - it should be able to cover areas outside the window as well. I can't even think of any application I'm using where tooltips don't do that, so it's what I'd expect in slint as well.