mod design_tokens;
pub mod egui_helpers;
pub mod icons;
mod layout_job_builder;
mod static_image_cache;
pub mod toasts;
mod toggle_switch;
pub use design_tokens::DesignTokens;
pub use icons::Icon;
pub use layout_job_builder::LayoutJobBuilder;
pub use static_image_cache::StaticImageCache;
pub use toggle_switch::toggle_switch;
pub const FULLSIZE_CONTENT: bool = cfg!(target_os = "macos");
pub const CUSTOM_WINDOW_DECORATIONS: bool = false; pub const NATIVE_WINDOW_BAR: bool = !FULLSIZE_CONTENT && !CUSTOM_WINDOW_DECORATIONS;
pub struct TopBarStyle {
pub height: f32,
pub indent: f32,
}
use std::{ops::RangeInclusive, sync::Arc};
use parking_lot::Mutex;
use egui::{pos2, Align2, Color32, Mesh, NumExt, Rect, Shape, Vec2};
#[derive(Clone)]
pub struct HypexUi {
pub egui_ctx: egui::Context,
pub design_tokens: DesignTokens,
pub static_image_cache: Arc<Mutex<StaticImageCache>>,
}
impl HypexUi {
pub fn load_and_apply(egui_ctx: &egui::Context) -> Self {
Self {
egui_ctx: egui_ctx.clone(),
design_tokens: DesignTokens::load_and_apply(egui_ctx),
static_image_cache: Arc::new(Mutex::new(StaticImageCache::default())),
}
}
pub fn rerun_logo(&self) -> Arc<egui_extras::RetainedImage> {
if self.egui_ctx.style().visuals.dark_mode {
self.static_image_cache.lock().get(
"logo_dark_mode",
include_bytes!("../data/logo_dark_mode.png"),
)
} else {
self.static_image_cache.lock().get(
"logo_light_mode",
include_bytes!("../data/logo_light_mode.png"),
)
}
}
pub fn view_padding() -> f32 {
12.0
}
pub fn window_rounding() -> f32 {
12.0
}
pub fn normal_rounding() -> f32 {
6.0
}
pub fn small_rounding() -> f32 {
4.0
}
pub fn table_line_height() -> f32 {
14.0
}
pub fn table_header_height() -> f32 {
20.0
}
pub fn top_bar_margin() -> egui::Margin {
egui::Margin::symmetric(8.0, 2.0)
}
pub fn top_bar_height() -> f32 {
44.0 }
pub fn title_bar_height() -> f32 {
28.0 }
pub fn native_window_rounding() -> f32 {
10.0
}
pub fn top_panel_frame(&self) -> egui::Frame {
let mut frame = egui::Frame {
inner_margin: Self::top_bar_margin(),
fill: self.design_tokens.top_bar_color,
..Default::default()
};
if CUSTOM_WINDOW_DECORATIONS {
frame.rounding.nw = Self::native_window_rounding();
frame.rounding.ne = Self::native_window_rounding();
}
frame
}
#[allow(clippy::unused_self)]
pub fn bottom_panel_margin(&self) -> egui::Vec2 {
egui::Vec2::splat(8.0)
}
pub fn bottom_panel_frame(&self) -> egui::Frame {
let margin_offset = self.design_tokens.bottom_bar_stroke.width * 0.5;
let margin = self.bottom_panel_margin();
let mut frame = egui::Frame {
fill: self.design_tokens.bottom_bar_color,
inner_margin: egui::Margin::symmetric(
margin.x + margin_offset,
margin.y + margin_offset,
),
outer_margin: egui::Margin {
left: -margin_offset,
right: -margin_offset,
top: self.design_tokens.bottom_bar_stroke.width,
bottom: -margin_offset,
},
stroke: self.design_tokens.bottom_bar_stroke,
rounding: self.design_tokens.bottom_bar_rounding,
..Default::default()
};
if CUSTOM_WINDOW_DECORATIONS {
frame.rounding.sw = Self::native_window_rounding();
frame.rounding.se = Self::native_window_rounding();
}
frame
}
pub fn small_icon_size() -> egui::Vec2 {
egui::Vec2::splat(12.0)
}
pub fn setup_table_header(_header: &mut egui_extras::TableRow<'_, '_>) {}
pub fn setup_table_body(body: &mut egui_extras::TableBody<'_>) {
body.ui_mut().spacing_mut().interact_size.y = Self::table_line_height();
}
#[must_use]
#[allow(clippy::unused_self)]
pub fn warning_text(&self, text: impl Into<String>) -> egui::RichText {
let style = self.egui_ctx.style();
egui::RichText::new(text)
.italics()
.color(style.visuals.warn_fg_color)
}
#[must_use]
#[allow(clippy::unused_self)]
pub fn error_text(&self, text: impl Into<String>) -> egui::RichText {
let style = self.egui_ctx.style();
egui::RichText::new(text)
.italics()
.color(style.visuals.error_fg_color)
}
pub fn loop_selection_color() -> egui::Color32 {
egui::Color32::from_rgb(1, 37, 105) }
pub fn loop_everything_color() -> egui::Color32 {
egui::Color32::from_rgb(2, 80, 45) }
pub fn paint_watermark(&self) {
let logo = self.rerun_logo();
let screen_rect = self.egui_ctx.screen_rect();
let size = logo.size_vec2();
let rect = Align2::RIGHT_BOTTOM
.align_size_within_rect(size, screen_rect)
.translate(-Vec2::splat(16.0));
let mut mesh = Mesh::with_texture(logo.texture_id(&self.egui_ctx));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
mesh.add_rect_with_uv(rect, uv, Color32::WHITE);
self.egui_ctx.debug_painter().add(Shape::mesh(mesh));
}
pub fn top_bar_style(
&self,
native_pixels_per_point: Option<f32>,
fullscreen: bool,
style_like_web: bool,
) -> TopBarStyle {
let gui_zoom = if let Some(native_pixels_per_point) = native_pixels_per_point {
native_pixels_per_point / self.egui_ctx.pixels_per_point()
} else {
1.0
};
let make_room_for_window_buttons = !style_like_web && {
#[cfg(target_os = "macos")]
{
crate::FULLSIZE_CONTENT && !fullscreen
}
#[cfg(not(target_os = "macos"))]
{
_ = fullscreen;
false
}
};
let native_buttons_size_in_native_scale = egui::vec2(64.0, 24.0); let height = if make_room_for_window_buttons {
let height = native_buttons_size_in_native_scale.y;
height.max(gui_zoom * native_buttons_size_in_native_scale.y)
} else {
Self::top_bar_height() - Self::top_bar_margin().sum().y
};
let indent = if make_room_for_window_buttons {
gui_zoom * native_buttons_size_in_native_scale.x
} else {
0.0
};
TopBarStyle { height, indent }
}
pub fn icon_image(&self, icon: &Icon) -> Arc<egui_extras::RetainedImage> {
self.static_image_cache.lock().get(icon.id, icon.png_bytes)
}
pub fn small_icon_button(&self, ui: &mut egui::Ui, icon: &Icon) -> egui::Response {
let size_points = Self::small_icon_size();
let image = self.icon_image(icon);
let texture_id = image.texture_id(ui.ctx());
let tint = ui.visuals().widgets.inactive.fg_stroke.color;
ui.add(egui::ImageButton::new(texture_id, size_points).tint(tint))
}
pub fn medium_icon_toggle_button(
&self,
ui: &mut egui::Ui,
icon: &Icon,
selected: &mut bool,
) -> egui::Response {
let size_points = egui::Vec2::splat(16.0); let image = self.icon_image(icon);
let texture_id = image.texture_id(ui.ctx());
let tint = if *selected {
ui.visuals().widgets.inactive.fg_stroke.color
} else {
egui::Color32::from_gray(100) };
let mut response = ui.add(egui::ImageButton::new(texture_id, size_points).tint(tint));
if response.clicked() {
*selected = !*selected;
response.mark_changed();
}
response
}
fn large_button_impl(
&self,
ui: &mut egui::Ui,
icon: &Icon,
bg_fill: Option<Color32>,
tint: Option<Color32>,
) -> egui::Response {
let prev_style = ui.style().clone();
{
let visuals = ui.visuals_mut();
visuals.widgets.inactive.weak_bg_fill = visuals.widgets.inactive.bg_fill;
visuals.widgets.hovered.expansion = 0.0;
visuals.widgets.active.expansion = 0.0;
visuals.widgets.open.expansion = 0.0;
}
let image = self.icon_image(icon);
let texture_id = image.texture_id(ui.ctx());
let button_size = Vec2::splat(28.0);
let icon_size = Vec2::splat(12.0); let rounding = 6.0;
let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click());
response.widget_info(|| egui::WidgetInfo::new(egui::WidgetType::ImageButton));
if ui.is_rect_visible(rect) {
let visuals = ui.style().interact(&response);
let bg_fill = bg_fill.unwrap_or(visuals.bg_fill);
let tint = tint.unwrap_or(visuals.fg_stroke.color);
let image_rect = egui::Align2::CENTER_CENTER.align_size_within_rect(icon_size, rect);
ui.painter()
.rect_filled(rect.expand(visuals.expansion), rounding, bg_fill);
let mut mesh = egui::Mesh::with_texture(texture_id);
let uv = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
mesh.add_rect_with_uv(image_rect, uv, tint);
ui.painter().add(egui::Shape::mesh(mesh));
}
ui.set_style(prev_style);
response
}
#[allow(clippy::unused_self)]
pub fn checkbox(
&self,
ui: &mut egui::Ui,
selected: &mut bool,
text: impl Into<egui::WidgetText>,
) -> egui::Response {
ui.scope(|ui| {
ui.visuals_mut().widgets.hovered.expansion = 0.0;
ui.visuals_mut().widgets.active.expansion = 0.0;
ui.visuals_mut().widgets.open.expansion = 0.0;
ui.checkbox(selected, text)
})
.inner
}
#[allow(clippy::unused_self)]
pub fn radio_value<Value: PartialEq>(
&self,
ui: &mut egui::Ui,
current_value: &mut Value,
alternative: Value,
text: impl Into<egui::WidgetText>,
) -> egui::Response {
ui.scope(|ui| {
ui.visuals_mut().widgets.hovered.expansion = 0.0;
ui.visuals_mut().widgets.active.expansion = 0.0;
ui.visuals_mut().widgets.open.expansion = 0.0;
ui.radio_value(current_value, alternative, text)
})
.inner
}
pub fn large_button(&self, ui: &mut egui::Ui, icon: &Icon) -> egui::Response {
self.large_button_impl(ui, icon, None, None)
}
pub fn large_button_selected(
&self,
ui: &mut egui::Ui,
icon: &Icon,
selected: bool,
) -> egui::Response {
let bg_fill = selected.then(|| ui.visuals().selection.bg_fill);
let tint = selected.then(|| ui.visuals().selection.stroke.color);
self.large_button_impl(ui, icon, bg_fill, tint)
}
pub fn visibility_toggle_button(
&self,
ui: &mut egui::Ui,
visible: &mut bool,
) -> egui::Response {
let mut response = if *visible && ui.is_enabled() {
self.small_icon_button(ui, &icons::VISIBLE)
} else {
self.small_icon_button(ui, &icons::INVISIBLE)
};
if response.clicked() {
response.mark_changed();
*visible = !*visible;
}
response
}
#[allow(clippy::unused_self)]
pub fn large_collapsing_header<R>(
&self,
ui: &mut egui::Ui,
label: &str,
default_open: bool,
add_body: impl FnOnce(&mut egui::Ui) -> R,
) {
let mut state = egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
ui.make_persistent_id(label),
default_open,
);
let openness = state.openness(ui.ctx());
let header_size = egui::vec2(ui.available_width(), 28.0);
ui.allocate_ui_with_layout(
header_size,
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let background_frame = ui.painter().add(egui::Shape::Noop);
let space_before_icon = 0.0;
let icon_width = ui.spacing().icon_width_inner;
let space_after_icon = ui.spacing().icon_spacing;
let font_id = egui::TextStyle::Button.resolve(ui.style());
let galley = ui.painter().layout_no_wrap(
label.to_owned(),
font_id,
Color32::TEMPORARY_COLOR,
);
let desired_size = header_size.at_least(
egui::vec2(space_before_icon + icon_width + space_after_icon, 0.0)
+ galley.size(),
);
let header_response = ui.allocate_response(desired_size, egui::Sense::click());
let rect = header_response.rect;
let icon_rect = egui::Rect::from_center_size(
header_response.rect.left_center()
+ egui::vec2(space_before_icon + icon_width / 2.0, 0.0),
egui::Vec2::splat(icon_width),
);
let icon_response = header_response.clone().with_new_rect(icon_rect);
egui::collapsing_header::paint_default_icon(ui, openness, &icon_response);
let visuals = ui.style().interact(&header_response);
let text_pos = icon_response.rect.right_center()
+ egui::vec2(space_after_icon, -0.5 * galley.size().y);
ui.painter()
.galley_with_color(text_pos, galley, visuals.text_color());
let bg_rect = rect.expand2(egui::vec2(1000.0, 0.0));
ui.painter().set(
background_frame,
Shape::rect_filled(bg_rect, 0.0, visuals.bg_fill),
);
if header_response.clicked() {
state.toggle(ui);
}
},
);
state.show_body_unindented(ui, |ui| {
ui.add_space(4.0); add_body(ui);
ui.add_space(4.0); });
}
#[allow(clippy::unused_self)]
pub fn grid_left_hand_label(&self, ui: &mut egui::Ui, label: &str) -> egui::Response {
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
ui.label(label)
})
.inner
}
#[allow(clippy::unused_self)]
pub fn selection_grid(&self, ui: &mut egui::Ui, id: &str) -> egui::Grid {
egui::Grid::new(id)
.num_columns(2)
.spacing(ui.style().spacing.item_spacing + egui::vec2(0.0, 8.0))
}
#[allow(clippy::unused_self)]
pub fn draw_shadow_line(&self, ui: &mut egui::Ui, rect: Rect, direction: egui::Direction) {
let color_dark = self.design_tokens.shadow_gradient_dark_start;
let color_bright = Color32::TRANSPARENT;
let (left_top, right_top, left_bottom, right_bottom) = match direction {
egui::Direction::RightToLeft => (color_bright, color_dark, color_bright, color_dark),
egui::Direction::LeftToRight => (color_dark, color_bright, color_dark, color_bright),
egui::Direction::BottomUp => (color_bright, color_bright, color_dark, color_dark),
egui::Direction::TopDown => (color_dark, color_dark, color_bright, color_bright),
};
use egui::epaint::Vertex;
let shadow = egui::Mesh {
indices: vec![0, 1, 2, 2, 1, 3],
vertices: vec![
Vertex {
pos: rect.left_top(),
uv: egui::epaint::WHITE_UV,
color: left_top,
},
Vertex {
pos: rect.right_top(),
uv: egui::epaint::WHITE_UV,
color: right_top,
},
Vertex {
pos: rect.left_bottom(),
uv: egui::epaint::WHITE_UV,
color: left_bottom,
},
Vertex {
pos: rect.right_bottom(),
uv: egui::epaint::WHITE_UV,
color: right_bottom,
},
],
texture_id: Default::default(),
};
ui.painter().add(shadow);
}
pub fn selectable_label_with_icon(
&self,
ui: &mut egui::Ui,
icon: &Icon,
text: impl Into<egui::WidgetText>,
selected: bool,
) -> egui::Response {
let button_padding = ui.spacing().button_padding;
let total_extra = button_padding + button_padding;
let wrap_width = ui.available_width() - total_extra.x;
let text = text
.into()
.into_galley(ui, None, wrap_width, egui::TextStyle::Button);
let text_to_icon_padding = 4.0;
let icon_width_plus_padding = Self::small_icon_size().x + text_to_icon_padding;
let mut desired_size = total_extra + text.size() + egui::vec2(icon_width_plus_padding, 0.0);
desired_size.y = desired_size
.y
.at_least(ui.spacing().interact_size.y)
.at_least(Self::small_icon_size().y);
let (rect, response) = ui.allocate_at_least(desired_size, egui::Sense::click());
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, selected, text.text())
});
if ui.is_rect_visible(rect) {
let visuals = ui.style().interact_selectable(&response, selected);
if selected || response.hovered() || response.highlighted() || response.has_focus() {
let rect = rect.expand(visuals.expansion);
ui.painter().rect(
rect,
visuals.rounding,
visuals.weak_bg_fill,
visuals.bg_stroke,
);
}
let image = self.icon_image(icon);
let texture_id = image.texture_id(ui.ctx());
let tint = ui.visuals().widgets.inactive.fg_stroke.color;
let image_rect = egui::Rect::from_min_size(
ui.painter().round_pos_to_pixels(egui::pos2(
rect.min.x.ceil(),
((rect.min.y + rect.max.y - Self::small_icon_size().y) * 0.5).ceil(),
)),
Self::small_icon_size(),
);
ui.painter().image(
texture_id,
image_rect,
egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
tint,
);
let mut text_rect = rect;
text_rect.min.x = image_rect.max.x + text_to_icon_padding;
let text_pos = ui
.layout()
.align_size_within_rect(text.size(), text_rect)
.min;
text.paint_with_visuals(ui.painter(), text_pos, &visuals);
}
response
}
pub fn text_format_body(&self) -> egui::TextFormat {
egui::TextFormat::simple(
egui::TextStyle::Body.resolve(&self.egui_ctx.style()),
self.egui_ctx.style().visuals.text_color(),
)
}
pub fn text_format_key(&self) -> egui::TextFormat {
let mut style = egui::TextFormat::simple(
egui::TextStyle::Monospace.resolve(&self.egui_ctx.style()),
self.egui_ctx.style().visuals.text_color(),
);
style.background = self.egui_ctx.style().visuals.widgets.noninteractive.bg_fill;
style
}
#[allow(clippy::unused_self)]
pub fn paint_time_cursor(
&self,
painter: &egui::Painter,
x: f32,
y: RangeInclusive<f32>,
stroke: egui::Stroke,
) {
let y_min = *y.start();
let y_max = *y.end();
let stroke = egui::Stroke {
width: 1.5 * stroke.width,
color: stroke.color,
};
let w = 10.0;
let triangle = vec![
pos2(x - 0.5 * w, y_min), pos2(x + 0.5 * w, y_min), pos2(x, y_min + w), ];
painter.add(egui::Shape::convex_polygon(
triangle,
stroke.color,
egui::Stroke::NONE,
));
painter.vline(x, (y_min + w)..=y_max, stroke);
}
}
#[cfg(feature = "eframe")]
#[cfg(not(target_arch = "wasm32"))]
pub fn native_window_buttons_ui(frame: &mut eframe::Frame, ui: &mut egui::Ui) {
use egui::{Button, RichText};
let button_height = 12.0;
let close_response = ui
.add(Button::new(RichText::new("❌").size(button_height)))
.on_hover_text("Close the window");
if close_response.clicked() {
frame.close();
}
if frame.info().window_info.maximized {
let maximized_response = ui
.add(Button::new(RichText::new("🗗").size(button_height)))
.on_hover_text("Restore window");
if maximized_response.clicked() {
frame.set_maximized(false);
}
} else {
let maximized_response = ui
.add(Button::new(RichText::new("🗗").size(button_height)))
.on_hover_text("Maximize window");
if maximized_response.clicked() {
frame.set_maximized(true);
}
}
let minimized_response = ui
.add(Button::new(RichText::new("🗕").size(button_height)))
.on_hover_text("Minimize the window");
if minimized_response.clicked() {
frame.set_minimized(true);
}
}
pub fn help_hover_button(ui: &mut egui::Ui) -> egui::Response {
ui.add(
egui::Label::new("❓").sense(egui::Sense::click()), )
}