feat: added tooltips (#837)

This commit is contained in:
Maciek 2026-09-16 18:34:50 +02:00 • committed by GitHub
parent 0a6c9e0f74
commit d32d3e1782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 408 additions and 11 deletions

View file

@ -141,6 +141,7 @@ fn cluster_settings_button(cluster_id: i64) -> impl IntoElement {
Button::new()
.ghost()
.icon()
.tooltip("Cluster settings")
.on_press(move |_| {
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
})

View file

@ -4,7 +4,7 @@ use std::borrow::Cow;
use freya::prelude::*;
use crate::{
components::{Icon, IconTint, IconType},
components::{Icon, IconTint, IconType, TooltipPlacement, tooltip::use_tooltip_anchor},
theme::colors,
};
@ -53,6 +53,8 @@ pub struct Button {
enabled: bool,
focusable: bool,
alt: Option<Box<str>>,
tooltip: Option<Box<str>>,
tooltip_placement: TooltipPlacement,
on_press: Option<EventHandler<Event<PressEventData>>>,
elements: Vec<Element>,
@ -111,6 +113,8 @@ impl Button {
enabled: true,
focusable: true,
alt: None,
tooltip: None,
tooltip_placement: TooltipPlacement::default(),
on_press: None,
elements: Vec::new(),
key: DiffKey::None,
@ -203,6 +207,20 @@ impl Button {
self
}
pub fn tooltip(mut self, tooltip: impl Into<Box<str>>) -> Self {
let tooltip = tooltip.into();
if self.alt.is_none() {
self.alt = Some(tooltip.clone());
}
self.tooltip = Some(tooltip);
self
}
pub fn tooltip_placement(mut self, placement: TooltipPlacement) -> Self {
self.tooltip_placement = placement;
self
}
pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self {
self.cursor_icon = cursor_icon;
self
@ -247,6 +265,8 @@ impl Component for Button {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let tooltip = use_tooltip_anchor(self.tooltip.clone(), self.tooltip_placement);
let enabled = use_reactive(&self.enabled);
let cursor_icon = self.cursor_icon;
@ -335,7 +355,7 @@ impl Component for Button {
rect = rect.cursor(CursorIcon::NotAllowed);
}
rect
tooltip.attach(rect)
}
fn render_key(&self) -> DiffKey {
@ -444,6 +464,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button {
Button::new()
.secondary()
.icon()
.tooltip("Open folder")
.on_press(move |_| {
std::fs::create_dir_all(&folder).ok();
crate::platform::open_path(&folder.to_string_lossy());

View file

@ -42,6 +42,7 @@ mod tab_bar;
mod text_input;
mod toasts;
mod toggle;
mod tooltip;
mod update_prompt;
mod version_card;
pub mod upload_mclogs;
@ -94,5 +95,6 @@ pub use tab_bar::{TabBar, TabItem};
pub use text_input::{TextInput, validate_memory, validate_number};
pub use toasts::Toasts;
pub use toggle::{toggle, toggle_controlled};
pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips};
pub use update_prompt::UpdatePromptOverlay;
pub use version_card::VersionCard;

View file

@ -261,17 +261,20 @@ impl Component for NavbarRight {
.spacing(8.)
.child(
super::navbar_button()
.tooltip("Notifications")
.child(notification_bell(unread))
.on_press(open_notifications),
)
.child(
super::navbar_button()
.tooltip("Settings")
.child(Icon::new(IconType::Settings02).size(20.))
.on_press(open_settings),
)
.child(
super::navbar_button()
.padding(0.0)
.tooltip("Switch account")
.on_press(open_account_switcher)
.child(
Avatar::new(account_uuid)

View file

@ -44,16 +44,19 @@ pub fn window_controls() -> impl IntoElement {
.layer(Layer::OverlayLevel(15))
.child(
navbar_button()
.tooltip("Minimize")
.child(Icon::new(IconType::Minus))
.on_press(minimize),
)
.child(
navbar_button()
.tooltip("Maximize")
.child(Icon::new(IconType::Maximize01).size(20.))
.on_press(maximize),
)
.child(
navbar_button()
.tooltip("Close")
.child(Icon::new(IconType::X))
.on_press(close),
)

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use crate::components::{Button, Icon, IconType};
use crate::components::{Button, Icon, IconType, TooltipPlacement};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -52,13 +52,18 @@ impl Component for Pagination {
let current = *page.read();
let last = total_pages.saturating_sub(1);
let icon_btn = move |target: usize, available: bool, icon: IconType| {
let icon_btn = move |target: usize,
available: bool,
icon: IconType,
tooltip: &'static str| {
let mut page = page;
let enabled = available && interactive;
Button::new()
.secondary()
.icon()
.enabled(enabled)
.tooltip(tooltip)
.tooltip_placement(TooltipPlacement::Top)
.on_press(move |_| page.set(target))
.child(Icon::new(icon).size(14.).color(if enabled {
colors::fg_primary()
@ -130,11 +135,17 @@ impl Component for Pagination {
.main_align(Alignment::Center)
.cross_align(Alignment::Center)
.spacing(6.)
.child(icon_btn(0, current > 0, IconType::ChevronsLeft))
.child(icon_btn(
0,
current > 0,
IconType::ChevronsLeft,
"First page",
))
.child(icon_btn(
current.saturating_sub(1),
current > 0,
IconType::ArrowLeft,
"Previous page",
));
if start > 0 {
@ -155,7 +166,17 @@ impl Component for Pagination {
row = row.child(num_btn(last));
}
row.child(icon_btn(current + 1, current < last, IconType::ArrowRight))
.child(icon_btn(last, current < last, IconType::ChevronsRight))
row.child(icon_btn(
current + 1,
current < last,
IconType::ArrowRight,
"Next page",
))
.child(icon_btn(
last,
current < last,
IconType::ChevronsRight,
"Last page",
))
}
}

View file

@ -324,6 +324,7 @@ fn header_row(
Button::new()
.ghost()
.icon()
.tooltip("Close")
.on_press(on_close)
.child(Icon::new(IconType::XClose).size(18.)),
)

View file

@ -11,7 +11,7 @@ use oneclient_events::Level;
use crate::{
ui::{divider},
components::{Button, ButtonSize, Icon, IconType, progress_track},
components::{Button, ButtonSize, Icon, IconType, TooltipPlacement, progress_track},
hooks::{use_dispatch, use_notifications_snapshot},
motion::use_animations_enabled,
notifications::{InboxEntry, NotificationActionKind},
@ -328,6 +328,8 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement {
.size(ButtonSize::Icon)
.width(Size::px(28.))
.height(Size::px(28.))
.tooltip("Dismiss")
.tooltip_placement(TooltipPlacement::Left)
.on_press(move |_| dispatch.dismiss_toast(id))
.child(
Icon::new(IconType::XClose)

View file

@ -0,0 +1,315 @@
use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::prelude::*;
use crate::components::OVERLAY_MAX_LEVEL;
use crate::theme::colors;
use crate::ui::{border_all_color, clamp_to_window, window_logical_size};
const SHOW_DELAY: Duration = Duration::from_millis(400);
const ANCHOR_GAP: f32 = 6.;
const MAX_WIDTH: f32 = 260.;
const FADE_MS: u64 = 110;
const HOVER_SLACK: f32 = 1.;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TooltipPlacement {
Top,
#[default]
Bottom,
Left,
Right,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct AnchorRect {
x: f32,
y: f32,
width: f32,
height: f32,
}
impl AnchorRect {
fn contains(&self, x: f32, y: f32) -> bool {
x >= self.x - HOVER_SLACK
&& x <= self.x + self.width + HOVER_SLACK
&& y >= self.y - HOVER_SLACK
&& y <= self.y + self.height + HOVER_SLACK
}
}
#[derive(Clone, PartialEq)]
struct ActiveTooltip {
owner: u64,
text: Box<str>,
anchor: AnchorRect,
placement: TooltipPlacement,
}
#[derive(Clone)]
struct Tooltips {
active: State<Option<ActiveTooltip>>,
next_owner: Rc<Cell<u64>>,
}
pub fn use_provide_tooltips() {
let active = use_state(|| None::<ActiveTooltip>);
let next_owner = use_hook(|| Rc::new(Cell::new(0u64)));
use_provide_root_context(move || Tooltips { active, next_owner });
}
#[derive(Clone)]
struct AnchorState {
tooltips: Tooltips,
owner: u64,
area: Rc<Cell<AnchorRect>>,
generation: Rc<Cell<u64>>,
text: Box<str>,
placement: TooltipPlacement,
}
pub struct TooltipAnchor(Option<AnchorState>);
pub fn use_tooltip_anchor(text: Option<Box<str>>, placement: TooltipPlacement) -> TooltipAnchor {
let tooltips = use_hook(try_consume_root_context::<Tooltips>);
let owner = use_hook(|| {
tooltips.as_ref().map_or(0, |tooltips| {
let owner = tooltips.next_owner.get() + 1;
tooltips.next_owner.set(owner);
owner
})
});
let area = use_hook(|| Rc::new(Cell::new(AnchorRect::default())));
let generation = use_hook(|| Rc::new(Cell::new(0u64)));
let abandoned = generation.clone();
use_drop(move || abandoned.set(abandoned.get() + 1));
TooltipAnchor(tooltips.zip(text).map(|(tooltips, text)| AnchorState {
tooltips,
owner,
area,
generation,
text,
placement,
}))
}
impl TooltipAnchor {
pub fn attach(&self, rect: Rect) -> Rect {
let Some(state) = self.0.clone() else {
return rect;
};
let sized = state.clone();
let enter = state.clone();
let leave = state.clone();
let pressed = state.clone();
let scrolled = state;
rect.on_sized(move |e: Event<SizedEventData>| {
let area = e.area;
sized.area.set(AnchorRect {
x: area.min_x(),
y: area.min_y(),
width: area.width(),
height: area.height(),
});
})
.on_pointer_enter(move |e: Event<PointerEventData>| {
let global = e.global_location();
let element = e.element_location();
let mut anchor = enter.area.get();
anchor.x = (global.x - element.x) as f32;
anchor.y = (global.y - element.y) as f32;
let generation = enter.generation.get() + 1;
enter.generation.set(generation);
let pending = enter.generation.clone();
let mut active = enter.tooltips.active;
let tooltip = ActiveTooltip {
owner: enter.owner,
text: enter.text.clone(),
anchor,
placement: enter.placement,
};
spawn(async move {
tokio::time::sleep(SHOW_DELAY).await;
if pending.get() == generation {
active.set(Some(tooltip));
}
});
})
.on_pointer_leave(move |_| hide(&leave))
.on_mouse_down(move |_| hide(&pressed))
.on_wheel(move |_| hide(&scrolled))
}
}
fn hide(state: &AnchorState) {
state.generation.set(state.generation.get() + 1);
clear_owner(state.tooltips.active, state.owner);
}
fn clear_owner(mut active: State<Option<ActiveTooltip>>, owner: u64) {
let shown = active.peek().as_ref().map(|tooltip| tooltip.owner);
if shown == Some(owner) {
active.set(None);
}
}
#[derive(PartialEq)]
pub struct TooltipHost;
impl Component for TooltipHost {
fn render(&self) -> impl IntoElement {
let Some(tooltips) = use_hook(try_consume_root_context::<Tooltips>) else {
return rect();
};
let state = tooltips.active;
let active = (*state.read()).clone();
let mut host = rect()
.layer(Layer::OverlayLevel(OVERLAY_MAX_LEVEL))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.interactive(false);
if let Some((owner, anchor)) = active.as_ref().map(|active| (active.owner, active.anchor)) {
host = host.on_global_pointer_move(move |e: Event<PointerEventData>| {
let cursor = e.global_location();
if !anchor.contains(cursor.x as f32, cursor.y as f32) {
clear_owner(state, owner);
}
});
}
host.maybe_child(active.map(|active| {
let owner = active.owner;
TooltipBubble {
text: active.text,
anchor: active.anchor,
placement: active.placement,
key: DiffKey::None,
}
.key(owner)
.into_element()
}))
}
}
#[derive(PartialEq)]
struct TooltipBubble {
text: Box<str>,
anchor: AnchorRect,
placement: TooltipPlacement,
key: DiffKey,
}
impl KeyExt for TooltipBubble {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for TooltipBubble {
fn render(&self) -> impl IntoElement {
let mut size = use_state(Size2D::zero);
let measured = *size.read();
let placed = measured.width > 0.;
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(FADE_MS).ease(Ease::Out)
});
let (x, y) = place(self.anchor, self.placement, measured);
rect()
.position(Position::new_global().top(y).left(x))
.interactive(false)
.opacity(if placed { fade.read().value() } else { 0. })
.max_width(Size::px(MAX_WIDTH))
.padding(Gaps::new_symmetric(5., 9.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.on_sized(move |e: Event<SizedEventData>| {
let bubble = e.area.size;
let previous = *size.peek();
if (bubble.width - previous.width).abs() > 0.5
|| (bubble.height - previous.height).abs() > 0.5
{
size.set(bubble);
}
})
.child(
label()
.text(self.text.to_string())
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.max_lines(2)
.color(colors::fg_primary()),
)
}
fn render_key(&self) -> DiffKey {
self.key.clone().or(self.default_key())
}
}
fn place(anchor: AnchorRect, placement: TooltipPlacement, size: Size2D) -> (f32, f32) {
let window = window_logical_size();
let centered_x = anchor.x + (anchor.width - size.width) / 2.;
let centered_y = anchor.y + (anchor.height - size.height) / 2.;
let above = anchor.y - size.height - ANCHOR_GAP;
let below = anchor.y + anchor.height + ANCHOR_GAP;
let before = anchor.x - size.width - ANCHOR_GAP;
let after = anchor.x + anchor.width + ANCHOR_GAP;
let fits_above = above >= 0.;
let fits_below = below + size.height <= window.height;
let fits_before = before >= 0.;
let fits_after = after + size.width <= window.width;
let (x, y) = match placement {
TooltipPlacement::Top => (
centered_x,
if fits_above || !fits_below { above } else { below },
),
TooltipPlacement::Bottom => (
centered_x,
if fits_below || !fits_above { below } else { above },
),
TooltipPlacement::Left => (
if fits_before || !fits_after {
before
} else {
after
},
centered_y,
),
TooltipPlacement::Right => (
if fits_after || !fits_before {
after
} else {
before
},
centered_y,
),
};
clamp_to_window(x, y, size.width, size.height)
}

View file

@ -274,6 +274,7 @@ fn cluster_header(
Button::new()
.danger()
.icon()
.tooltip("Stop game")
.on_press(move |_| kill_dispatch.kill_cluster(cluster_id))
.child(Icon::new(IconType::Square).size(16.)),
)

View file

@ -3,8 +3,8 @@ use freya::router::*;
use crate::components::{
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay,
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts,
UpdatePromptOverlay,
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost,
UpdatePromptOverlay, use_provide_tooltips,
};
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
#[cfg(not(target_os = "macos"))]
@ -24,6 +24,7 @@ impl Component for RootLayout {
let home_ready = use_state(|| false);
use_provide_splash(SplashState { active, home_ready });
use_provide_overlay_claims();
use_provide_tooltips();
// macOS rounds the window natively so Freya must not round on top of it
#[cfg(target_os = "macos")]
@ -83,6 +84,7 @@ impl Component for RootLayout {
.child(OptionalModsPopup)
.child(PackageUpdatePopup)
.child(StatusBar)
.child(TooltipHost)
.child(SplashCurtain)
.child(AnimationClockDriver)
.child(HomeArtPrefetch)

View file

@ -214,3 +214,14 @@ impl ImageFallbackExt for ImageViewer {
self.error_renderer(move |_: String| placeholder.clone())
}
}
pub fn window_logical_size() -> Size2D {
let platform = Platform::get();
let scale = *platform.scale_factor.peek() as f32;
let size = *platform.root_size.peek();
if scale <= 0. {
return size;
}
Size2D::new(size.width / scale, size.height / scale)
}

View file

@ -352,6 +352,7 @@ fn header_row(
Button::new()
.ghost()
.icon()
.tooltip("Close")
.on_press(on_close)
.child(Icon::new(IconType::XClose).size(18.)),
)

View file

@ -185,6 +185,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen
.small()
.ghost()
.icon()
.tooltip(if overridden {
"Reset to the global setting"
} else {
"Already using the global setting"
})
.corner_radius(CornerRadius::new_all(7.))
.maybe(overridden, |el| el.on_press(move |_| on_reset.call(())))
.enabled(overridden)

View file

@ -296,6 +296,7 @@ impl Component for FilterButton {
Button::new()
.secondary()
.icon()
.tooltip("Sort and filter")
.width(Size::px(FILTER_BTN_W))
.height(Size::px(34.))
.on_press(move |e: Event<PressEventData>| {

View file

@ -523,6 +523,7 @@ impl Component for AccountRow {
Button::new()
.ghost()
.icon()
.tooltip("Refresh this account")
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
if *refreshing.peek() {
@ -548,7 +549,12 @@ impl Component for AccountRow {
.into_element()
}))
.child(
Button::new().ghost().icon().enabled(false).child(
Button::new()
.ghost()
.icon()
.enabled(false)
.tooltip("Edit")
.child(
Icon::new(IconType::Pencil01)
.size(18.)
.color(colors::fg_secondary()),
@ -558,6 +564,7 @@ impl Component for AccountRow {
Button::new()
.ghost()
.icon()
.tooltip("Remove account")
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
remove.mutate(RemoveAccountKeys { id });