1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314
use egui::{text::TextWrapping, Align, Align2, NumExt, Ui};
use super::{ContentContext, DesiredWidth, ListItemContent};
use crate::{DesignTokens, Icon, LabelStyle};
/// [`ListItemContent`] that displays a simple label with optional icon and buttons.
#[allow(clippy::type_complexity)]
pub struct LabelContent<'a> {
text: egui::WidgetText,
//TODO(ab): these should probably go as WidgetText already implements that
subdued: bool,
weak: bool,
italics: bool,
label_style: LabelStyle,
icon_fn: Option<Box<dyn FnOnce(&mut egui::Ui, egui::Rect, egui::style::WidgetVisuals) + 'a>>,
buttons_fn: Option<Box<dyn FnOnce(&mut egui::Ui) -> egui::Response + 'a>>,
always_show_buttons: bool,
text_wrap_mode: Option<egui::TextWrapMode>,
min_desired_width: Option<f32>,
}
impl<'a> LabelContent<'a> {
pub fn new(text: impl Into<egui::WidgetText>) -> Self {
Self {
text: text.into(),
subdued: false,
weak: false,
italics: false,
label_style: Default::default(),
icon_fn: None,
buttons_fn: None,
always_show_buttons: false,
text_wrap_mode: None,
min_desired_width: None,
}
}
/// Set the subdued state of the item.
///
/// Note: takes precedence over [`Self::weak`] if set.
// TODO(ab): this is a hack to implement the behavior of the blueprint tree UI, where active
// widget are displayed in a subdued state (container, hidden space views/entities). One
// slightly more correct way would be to override the color using a (color, index) pair
// related to the design system table.
#[inline]
pub fn subdued(mut self, subdued: bool) -> Self {
self.subdued = subdued;
self
}
/// Set the weak state of the item.
///
/// Note: [`Self::subdued`] takes precedence if set.
// TODO(ab): should use design token instead
#[inline]
pub fn weak(mut self, weak: bool) -> Self {
self.weak = weak;
self
}
/// Render text in italic.
// TODO(ab): should use design token instead
#[inline]
pub fn italics(mut self, italics: bool) -> Self {
self.italics = italics;
self
}
/// Style the label for an unnamed items.
///
/// The styling is applied on top of to [`Self::weak`] and [`Self::subdued`]. It also implies [`Self::italics`].
// TODO(ab): should use design token instead
#[inline]
pub fn label_style(mut self, style: crate::LabelStyle) -> Self {
self.label_style = style;
self
}
/// Should we truncate text if it is too long?
#[inline]
pub fn truncate(mut self, truncate: bool) -> Self {
self.text_wrap_mode = Some(if truncate {
egui::TextWrapMode::Truncate
} else {
egui::TextWrapMode::Extend
});
self
}
/// Set the minimum desired for the content.
///
/// This defaults to zero.
#[inline]
pub fn min_desired_width(mut self, min_desired_width: f32) -> Self {
self.min_desired_width = Some(min_desired_width);
self
}
/// Provide an [`Icon`] to be displayed on the left of the item.
#[inline]
pub fn with_icon(self, icon: &'a Icon) -> Self {
self.with_icon_fn(|ui, rect, visuals| {
let tint = visuals.fg_stroke.color;
icon.as_image().tint(tint).paint_at(ui, rect);
})
}
/// Provide a custom closure to draw an icon on the left of the item.
#[inline]
pub fn with_icon_fn(
mut self,
icon_fn: impl FnOnce(&mut egui::Ui, egui::Rect, egui::style::WidgetVisuals) + 'a,
) -> Self {
self.icon_fn = Some(Box::new(icon_fn));
self
}
/// Provide a closure to display on-hover buttons on the right of the item.
///
/// Buttons also show when the item is selected, in order to support clicking them on touch
/// screens. The buttons can be set to be always shown with [`Self::always_show_buttons`].
///
/// Notes:
/// - If buttons are used, the item will allocate the full available width of the parent. If the
/// enclosing UI adapts to the childrens width, it will unnecessarily grow. If buttons aren't
/// used, the item will only allocate the width needed for the text and icons if any.
/// - A right to left layout is used, so the right-most button must be added first.
// TODO(#6191): This should reconciled this with the `ItemButton` abstraction by using something
// like `Vec<Box<dyn ItemButton>>` instead of a generic closure.
#[inline]
pub fn with_buttons(
mut self,
buttons: impl FnOnce(&mut egui::Ui) -> egui::Response + 'a,
) -> Self {
self.buttons_fn = Some(Box::new(buttons));
self
}
/// Always show the buttons.
///
/// By default, buttons are only shown when the item is hovered or selected. By setting this to
/// `true`, the buttons are always shown.
#[inline]
pub fn always_show_buttons(mut self, always_show_buttons: bool) -> Self {
self.always_show_buttons = always_show_buttons;
self
}
fn get_text_wrap_mode(&self, ui: &egui::Ui) -> egui::TextWrapMode {
if let Some(text_wrap_mode) = self.text_wrap_mode {
text_wrap_mode
} else if crate::is_in_resizable_panel(ui) {
egui::TextWrapMode::Truncate // The user can resize the panl to see the full text
} else {
egui::TextWrapMode::Extend // Show everything
}
}
}
impl ListItemContent for LabelContent<'_> {
fn ui(self: Box<Self>, ui: &mut Ui, context: &ContentContext<'_>) {
let text_wrap_mode = self.get_text_wrap_mode(ui);
let Self {
mut text,
subdued,
weak,
italics,
label_style,
icon_fn,
buttons_fn,
always_show_buttons,
text_wrap_mode: _,
min_desired_width: _,
} = *self;
let icon_rect = egui::Rect::from_center_size(
context.rect.left_center() + egui::vec2(DesignTokens::small_icon_size().x / 2., 0.0),
DesignTokens::small_icon_size(),
);
let mut text_rect = context.rect;
if icon_fn.is_some() {
text_rect.min.x += icon_rect.width() + DesignTokens::text_to_icon_padding();
}
// text styling
if italics || label_style == LabelStyle::Unnamed {
text = text.italics();
}
let mut visuals = ui
.style()
.interact_selectable(context.response, context.list_item.selected);
// TODO(ab): use design tokens instead
if weak {
visuals.fg_stroke.color = ui.visuals().weak_text_color();
} else if subdued {
visuals.fg_stroke.color = visuals.fg_stroke.color.gamma_multiply(0.5);
}
match label_style {
LabelStyle::Normal => {}
LabelStyle::Unnamed => {
text = text.color(visuals.fg_stroke.color.gamma_multiply(0.5));
}
}
// Draw icon
if let Some(icon_fn) = icon_fn {
icon_fn(ui, icon_rect, visuals);
}
// We can't use `.hovered()` or the buttons disappear just as the user clicks,
// so we use `contains_pointer` instead. That also means we need to check
// that we aren't dragging anything.
// By showing the buttons when selected, we allow users to find them on touch screens.
let should_show_buttons = (context.list_item.interactive
&& ui.rect_contains_pointer(context.bg_rect)
&& !egui::DragAndDrop::has_any_payload(ui.ctx()))
|| context.list_item.selected
|| always_show_buttons;
let button_response = if should_show_buttons {
if let Some(buttons) = buttons_fn {
let mut ui = ui.new_child(
egui::UiBuilder::new()
.max_rect(text_rect)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
Some(buttons(&mut ui))
} else {
None
}
} else {
None
};
// Draw text
if let Some(button_response) = &button_response {
text_rect.max.x -= button_response.rect.width() + DesignTokens::text_to_icon_padding();
}
let mut layout_job =
text.into_layout_job(ui.style(), egui::FontSelection::Default, Align::LEFT);
layout_job.wrap = TextWrapping::from_wrap_mode_and_width(text_wrap_mode, text_rect.width());
let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
// this happens here to avoid cloning the text
context.response.widget_info(|| {
egui::WidgetInfo::selected(
egui::WidgetType::SelectableLabel,
ui.is_enabled(),
context.list_item.selected,
galley.text(),
)
});
let text_pos = Align2::LEFT_CENTER
.align_size_within_rect(galley.size(), text_rect)
.min;
ui.painter().galley(text_pos, galley, visuals.text_color());
}
fn desired_width(&self, ui: &Ui) -> DesiredWidth {
let text_wrap_mode = self.get_text_wrap_mode(ui);
let measured_width = {
//TODO(ab): ideally there wouldn't be as much code duplication with `Self::ui`
let mut text = self.text.clone();
if self.italics || self.label_style == LabelStyle::Unnamed {
text = text.italics();
}
let layout_job =
text.clone()
.into_layout_job(ui.style(), egui::FontSelection::Default, Align::LEFT);
let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
let mut desired_width = galley.size().x;
if self.icon_fn.is_some() {
desired_width +=
DesignTokens::small_icon_size().x + DesignTokens::text_to_icon_padding();
}
// The `ceil()` is needed to avoid some rounding errors which leads to text being
// truncated even though we allocated enough space.
desired_width.ceil()
};
if text_wrap_mode == egui::TextWrapMode::Extend {
let min_desired_width = self.min_desired_width.unwrap_or(0.0);
DesiredWidth::Exact(measured_width.at_least(min_desired_width))
} else {
// If the user set an explicit min-width, use it.
// Otherwise, show at least `default_min_width`, unless the text is even short.
let default_min_width = 64.0;
let min_desired_width = self
.min_desired_width
.unwrap_or_else(|| measured_width.min(default_min_width));
DesiredWidth::AtLeast(min_desired_width)
}
}
}