1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 23:00:04 -04:00

Merge branch 'main' into lucas/popup-footgun

This commit is contained in:
Emil Ernerfeldt
2025-07-09 10:45:45 +02:00
96 changed files with 1296 additions and 642 deletions

View File

@@ -97,9 +97,8 @@ fn vs_main(
@fragment @fragment
fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> {
// We always have an sRGB aware texture at the moment. // We expect "normal" textures that are NOT sRGB-aware.
let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); let tex_gamma = textureSample(r_tex_color, r_tex_sampler, in.tex_coord);
let tex_gamma = gamma_from_linear_rgba(tex_linear);
var out_color_gamma = in.color * tex_gamma; var out_color_gamma = in.color * tex_gamma;
// Dither the float color down to eight bits to reduce banding. // Dither the float color down to eight bits to reduce banding.
// This step is optional for egui backends. // This step is optional for egui backends.
@@ -115,9 +114,8 @@ fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> {
@fragment @fragment
fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> {
// We always have an sRGB aware texture at the moment. // We expect "normal" textures that are NOT sRGB-aware.
let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); let tex_gamma = textureSample(r_tex_color, r_tex_sampler, in.tex_coord);
let tex_gamma = gamma_from_linear_rgba(tex_linear);
var out_color_gamma = in.color * tex_gamma; var out_color_gamma = in.color * tex_gamma;
// Dither the float color down to eight bits to reduce banding. // Dither the float color down to eight bits to reduce banding.
// This step is optional for egui backends. // This step is optional for egui backends.

View File

@@ -564,19 +564,6 @@ impl Renderer {
); );
Cow::Borrowed(&image.pixels) Cow::Borrowed(&image.pixels)
} }
epaint::ImageData::Font(image) => {
assert_eq!(
width as usize * height as usize,
image.pixels.len(),
"Mismatch between texture size and texel count"
);
profiling::scope!("font -> sRGBA");
Cow::Owned(
image
.srgba_pixels(Default::default())
.collect::<Vec<epaint::Color32>>(),
)
}
}; };
let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice()); let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice());
@@ -642,9 +629,9 @@ impl Renderer {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb, // Minspec for wgpu WebGL emulation is WebGL2, so this should always be supported. format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb], view_formats: &[wgpu::TextureFormat::Rgba8Unorm],
}) })
}; };
let origin = wgpu::Origin3d::ZERO; let origin = wgpu::Origin3d::ZERO;
@@ -703,7 +690,7 @@ impl Renderer {
/// ///
/// This enables the application to reference the texture inside an image ui element. /// This enables the application to reference the texture inside an image ui element.
/// This effectively enables off-screen rendering inside the egui UI. Texture must have /// This effectively enables off-screen rendering inside the egui UI. Texture must have
/// the texture format [`wgpu::TextureFormat::Rgba8UnormSrgb`]. /// the texture format [`wgpu::TextureFormat::Rgba8Unorm`].
pub fn register_native_texture( pub fn register_native_texture(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
@@ -751,7 +738,7 @@ impl Renderer {
/// This allows applications to specify individual minification/magnification filters as well as /// This allows applications to specify individual minification/magnification filters as well as
/// custom mipmap and tiling options. /// custom mipmap and tiling options.
/// ///
/// The texture must have the format [`wgpu::TextureFormat::Rgba8UnormSrgb`]. /// The texture must have the format [`wgpu::TextureFormat::Rgba8Unorm`].
/// Any compare function supplied in the [`wgpu::SamplerDescriptor`] will be ignored. /// Any compare function supplied in the [`wgpu::SamplerDescriptor`] will be ignored.
#[expect(clippy::needless_pass_by_value)] // false positive #[expect(clippy::needless_pass_by_value)] // false positive
pub fn register_native_texture_with_sampler_options( pub fn register_native_texture_with_sampler_options(

View File

@@ -81,7 +81,7 @@ impl<'a> Atom<'a> {
wrap_mode = Some(TextWrapMode::Truncate); wrap_mode = Some(TextWrapMode::Truncate);
} }
let (preferred, kind) = self.kind.into_sized(ui, available_size, wrap_mode); let (intrinsic, kind) = self.kind.into_sized(ui, available_size, wrap_mode);
let size = self let size = self
.size .size
@@ -89,7 +89,7 @@ impl<'a> Atom<'a> {
SizedAtom { SizedAtom {
size, size,
preferred_size: preferred, intrinsic_size: intrinsic.at_least(self.size.unwrap_or_default()),
grow: self.grow, grow: self.grow,
kind, kind,
} }

View File

@@ -81,11 +81,10 @@ impl<'a> AtomKind<'a> {
) -> (Vec2, SizedAtomKind<'a>) { ) -> (Vec2, SizedAtomKind<'a>) {
match self { match self {
AtomKind::Text(text) => { AtomKind::Text(text) => {
let galley = text.into_galley(ui, wrap_mode, available_size.x, TextStyle::Button); let wrap_mode = wrap_mode.unwrap_or(ui.wrap_mode());
( let galley =
galley.size(), // TODO(#5762): calculate the preferred size text.into_galley(ui, Some(wrap_mode), available_size.x, TextStyle::Button);
SizedAtomKind::Text(galley), (galley.intrinsic_size, SizedAtomKind::Text(galley))
)
} }
AtomKind::Image(image) => { AtomKind::Image(image) => {
let size = image.load_and_calc_size(ui, available_size); let size = image.load_and_calc_size(ui, available_size);

View File

@@ -183,10 +183,10 @@ impl<'a> AtomLayout<'a> {
let mut desired_width = 0.0; let mut desired_width = 0.0;
// Preferred width / height is the ideal size of the widget, e.g. the size where the // intrinsic width / height is the ideal size of the widget, e.g. the size where the
// text is not wrapped. Used to set Response::intrinsic_size. // text is not wrapped. Used to set Response::intrinsic_size.
let mut preferred_width = 0.0; let mut intrinsic_width = 0.0;
let mut preferred_height = 0.0; let mut intrinsic_height = 0.0;
let mut height: f32 = 0.0; let mut height: f32 = 0.0;
@@ -203,7 +203,7 @@ impl<'a> AtomLayout<'a> {
if atoms.len() > 1 { if atoms.len() > 1 {
let gap_space = gap * (atoms.len() as f32 - 1.0); let gap_space = gap * (atoms.len() as f32 - 1.0);
desired_width += gap_space; desired_width += gap_space;
preferred_width += gap_space; intrinsic_width += gap_space;
} }
for (idx, item) in atoms.into_iter().enumerate() { for (idx, item) in atoms.into_iter().enumerate() {
@@ -224,10 +224,10 @@ impl<'a> AtomLayout<'a> {
let size = sized.size; let size = sized.size;
desired_width += size.x; desired_width += size.x;
preferred_width += sized.preferred_size.x; intrinsic_width += sized.intrinsic_size.x;
height = height.at_least(size.y); height = height.at_least(size.y);
preferred_height = preferred_height.at_least(sized.preferred_size.y); intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y);
sized_items.push(sized); sized_items.push(sized);
} }
@@ -243,10 +243,10 @@ impl<'a> AtomLayout<'a> {
let size = sized.size; let size = sized.size;
desired_width += size.x; desired_width += size.x;
preferred_width += sized.preferred_size.x; intrinsic_width += sized.intrinsic_size.x;
height = height.at_least(size.y); height = height.at_least(size.y);
preferred_height = preferred_height.at_least(sized.preferred_size.y); intrinsic_height = intrinsic_height.at_least(sized.intrinsic_size.y);
sized_items.insert(index, sized); sized_items.insert(index, sized);
} }
@@ -259,7 +259,7 @@ impl<'a> AtomLayout<'a> {
let mut response = ui.interact(rect, id, sense); let mut response = ui.interact(rect, id, sense);
response.intrinsic_size = response.intrinsic_size =
Some((Vec2::new(preferred_width, preferred_height) + margin.sum()).at_least(min_size)); Some((Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size));
AllocatedAtomLayout { AllocatedAtomLayout {
sized_atoms: sized_items, sized_atoms: sized_items,

View File

@@ -12,8 +12,8 @@ pub struct SizedAtom<'a> {
/// size.x + gap. /// size.x + gap.
pub size: Vec2, pub size: Vec2,
/// Preferred size of the atom. This is used to calculate `Response::intrinsic_size`. /// Intrinsic size of the atom. This is used to calculate `Response::intrinsic_size`.
pub preferred_size: Vec2, pub intrinsic_size: Vec2,
pub kind: SizedAtomKind<'a>, pub kind: SizedAtomKind<'a>,
} }

View File

@@ -121,6 +121,7 @@ pub struct Area {
new_pos: Option<Pos2>, new_pos: Option<Pos2>,
fade_in: bool, fade_in: bool,
layout: Layout, layout: Layout,
sizing_pass: bool,
} }
impl WidgetWithState for Area { impl WidgetWithState for Area {
@@ -147,6 +148,7 @@ impl Area {
anchor: None, anchor: None,
fade_in: true, fade_in: true,
layout: Layout::default(), layout: Layout::default(),
sizing_pass: false,
} }
} }
@@ -357,6 +359,27 @@ impl Area {
self.layout = layout; self.layout = layout;
self self
} }
/// While true, a sizing pass will be done. This means the area will be invisible
/// and the contents will be laid out to estimate the proper containing size of the area.
/// If false, there will be no change to the default area behavior. This is useful if the
/// area contents area dynamic and you need to need to make sure the area adjusts its size
/// accordingly.
///
/// This should only be set to true during the specific frames you want force a sizing pass.
/// Do NOT hard-code this as `.sizing_pass(true)`, as it will cause the area to never be
/// visible.
///
/// # Arguments
/// - resize: If true, the area will be resized to fit its contents. False will keep the
/// default area resizing behavior.
///
/// Default: `false`.
#[inline]
pub fn sizing_pass(mut self, resize: bool) -> Self {
self.sizing_pass = resize;
self
}
} }
pub(crate) struct Prepared { pub(crate) struct Prepared {
@@ -410,6 +433,7 @@ impl Area {
constrain_rect, constrain_rect,
fade_in, fade_in,
layout, layout,
sizing_pass: force_sizing_pass,
} = self; } = self;
let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect()); let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect());
@@ -425,6 +449,10 @@ impl Area {
interactable, interactable,
last_became_visible_at: None, last_became_visible_at: None,
}); });
if force_sizing_pass {
sizing_pass = true;
state.size = None;
}
state.pivot = pivot; state.pivot = pivot;
state.interactable = interactable; state.interactable = interactable;
if let Some(new_pos) = new_pos { if let Some(new_pos) = new_pos {

View File

@@ -160,6 +160,7 @@ impl From<PopupKind> for UiKind {
} }
} }
#[must_use = "Call `.show()` to actually display the popup"]
pub struct Popup<'a> { pub struct Popup<'a> {
id: Id, id: Id,
ctx: Context, ctx: Context,
@@ -210,6 +211,57 @@ impl<'a> Popup<'a> {
} }
} }
/// Show a popup relative to some widget.
/// The popup will be always open.
///
/// See [`Self::menu`] and [`Self::context_menu`] for common use cases.
pub fn from_response(response: &Response) -> Self {
let mut popup = Self::new(
response.id.with("popup"),
response.ctx.clone(),
response,
response.layer_id,
);
popup.widget_clicked_elsewhere = response.clicked_elsewhere();
popup
}
/// Show a popup relative to some widget,
/// toggling the open state based on the widget's click state.
///
/// See [`Self::menu`] and [`Self::context_menu`] for common use cases.
pub fn from_toggle_button_response(button_response: &Response) -> Self {
Self::from_response(button_response)
.open_memory(button_response.clicked().then_some(SetOpenCommand::Toggle))
}
/// Show a popup when the widget was clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`.
pub fn menu(button_response: &Response) -> Self {
Self::from_toggle_button_response(button_response)
.kind(PopupKind::Menu)
.layout(Layout::top_down_justified(Align::Min))
.style(menu_style)
.gap(0.0)
}
/// Show a context menu when the widget was secondary clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`.
/// In contrast to [`Self::menu`], this will open at the pointer position.
pub fn context_menu(response: &Response) -> Self {
Self::menu(response)
.open_memory(if response.secondary_clicked() {
Some(SetOpenCommand::Bool(true))
} else if response.clicked() {
// Explicitly close the menu if the widget was clicked
// Without this, the context menu would stay open if the user clicks the widget
Some(SetOpenCommand::Bool(false))
} else {
None
})
.at_pointer_fixed()
}
/// Set the kind of the popup. Used for [`Area::kind`] and [`Area::order`]. /// Set the kind of the popup. Used for [`Area::kind`] and [`Area::order`].
#[inline] #[inline]
pub fn kind(mut self, kind: PopupKind) -> Self { pub fn kind(mut self, kind: PopupKind) -> Self {
@@ -242,49 +294,6 @@ impl<'a> Popup<'a> {
self self
} }
/// Show a popup relative to some widget.
/// The popup will be always open.
///
/// See [`Self::menu`] and [`Self::context_menu`] for common use cases.
pub fn from_response(response: &Response) -> Self {
let mut popup = Self::new(
response.id.with("popup"),
response.ctx.clone(),
response,
response.layer_id,
);
popup.widget_clicked_elsewhere = response.clicked_elsewhere();
popup
}
/// Show a popup when the widget was clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`.
pub fn menu(response: &Response) -> Self {
Self::from_response(response)
.open_memory(response.clicked().then_some(SetOpenCommand::Toggle))
.kind(PopupKind::Menu)
.layout(Layout::top_down_justified(Align::Min))
.style(menu_style)
.gap(0.0)
}
/// Show a context menu when the widget was secondary clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`.
/// In contrast to [`Self::menu`], this will open at the pointer position.
pub fn context_menu(response: &Response) -> Self {
Self::menu(response)
.open_memory(if response.secondary_clicked() {
Some(SetOpenCommand::Bool(true))
} else if response.clicked() {
// Explicitly close the menu if the widget was clicked
// Without this, the context menu would stay open if the user clicks the widget
Some(SetOpenCommand::Bool(false))
} else {
None
})
.at_pointer_fixed()
}
/// Force the popup to be open or closed. /// Force the popup to be open or closed.
#[inline] #[inline]
pub fn open(mut self, open: bool) -> Self { pub fn open(mut self, open: bool) -> Self {
@@ -484,37 +493,18 @@ impl<'a> Popup<'a> {
self.gap, self.gap,
expected_popup_size, expected_popup_size,
) )
.unwrap_or_default()
} }
/// Show the popup. /// Show the popup.
/// Returns `None` if the popup is not open or anchor is `PopupAnchor::Pointer` and there is /// Returns `None` if the popup is not open or anchor is `PopupAnchor::Pointer` and there is
/// no pointer. /// no pointer.
pub fn show<R>(self, content: impl FnOnce(&mut Ui) -> R) -> Option<InnerResponse<R>> { pub fn show<R>(self, content: impl FnOnce(&mut Ui) -> R) -> Option<InnerResponse<R>> {
let best_align = self.get_best_align(); let hover_pos = self.ctx.pointer_hover_pos();
let Popup { let id = self.id;
id, if let OpenKind::Memory { set } = self.open_kind {
ctx, self.ctx.memory_mut(|mem| match set {
anchor,
open_kind,
close_behavior,
kind,
info,
layer_id,
rect_align: _,
alternative_aligns: _,
gap,
widget_clicked_elsewhere,
width,
sense,
layout,
frame,
style,
} = self;
let hover_pos = ctx.pointer_hover_pos();
if let OpenKind::Memory { set, .. } = open_kind {
ctx.memory_mut(|mem| match set {
Some(SetOpenCommand::Bool(open)) => { Some(SetOpenCommand::Bool(open)) => {
if open { if open {
match self.anchor { match self.anchor {
@@ -536,10 +526,32 @@ impl<'a> Popup<'a> {
}); });
} }
if !open_kind.is_open(id, &ctx) { if !self.open_kind.is_open(self.id, &self.ctx) {
return None; return None;
} }
let best_align = self.get_best_align();
let Popup {
id,
ctx,
anchor,
open_kind,
close_behavior,
kind,
info,
layer_id,
rect_align: _,
alternative_aligns: _,
gap,
widget_clicked_elsewhere,
width,
sense,
layout,
frame,
style,
} = self;
if kind != PopupKind::Tooltip { if kind != PopupKind::Tooltip {
ctx.pass_state_mut(|fs| { ctx.pass_state_mut(|fs| {
fs.layers fs.layers

View File

@@ -1,4 +1,4 @@
use emath::Align; use emath::{Align, NumExt as _};
use crate::{Layout, Ui, UiBuilder}; use crate::{Layout, Ui, UiBuilder};
@@ -20,8 +20,13 @@ use crate::{Layout, Ui, UiBuilder};
/// ///
/// If the parent is not wide enough to fit all widgets, the parent will be expanded to the right. /// If the parent is not wide enough to fit all widgets, the parent will be expanded to the right.
/// ///
/// The left widgets are first added to the ui, left-to-right. /// The left widgets are added left-to-right.
/// Then the right widgets are added, right-to-left. /// The right widgets are added right-to-left.
///
/// Which side is first depends on the configuration:
/// - [`Sides::extend`] - left widgets are added first
/// - [`Sides::shrink_left`] - right widgets are added first
/// - [`Sides::shrink_right`] - left widgets are added first
/// ///
/// ``` /// ```
/// # egui::__run_test_ui(|ui| { /// # egui::__run_test_ui(|ui| {
@@ -40,6 +45,16 @@ use crate::{Layout, Ui, UiBuilder};
pub struct Sides { pub struct Sides {
height: Option<f32>, height: Option<f32>,
spacing: Option<f32>, spacing: Option<f32>,
kind: SidesKind,
wrap_mode: Option<crate::TextWrapMode>,
}
#[derive(Clone, Copy, Debug, Default)]
enum SidesKind {
#[default]
Extend,
ShrinkLeft,
ShrinkRight,
} }
impl Sides { impl Sides {
@@ -68,58 +83,175 @@ impl Sides {
self self
} }
/// Try to shrink widgets on the left side.
///
/// Right widgets will be added first. The left [`Ui`]s max rect will be limited to the
/// remaining space.
#[inline]
pub fn shrink_left(mut self) -> Self {
self.kind = SidesKind::ShrinkLeft;
self
}
/// Try to shrink widgets on the right side.
///
/// Left widgets will be added first. The right [`Ui`]s max rect will be limited to the
/// remaining space.
#[inline]
pub fn shrink_right(mut self) -> Self {
self.kind = SidesKind::ShrinkRight;
self
}
/// Extend the left and right sides to fill the available space.
///
/// This is the default behavior.
/// The left widgets will be added first, followed by the right widgets.
#[inline]
pub fn extend(mut self) -> Self {
self.kind = SidesKind::Extend;
self
}
/// The text wrap mode for the shrinking side.
///
/// Does nothing if [`Self::extend`] is used (the default).
#[inline]
pub fn wrap_mode(mut self, wrap_mode: crate::TextWrapMode) -> Self {
self.wrap_mode = Some(wrap_mode);
self
}
/// Truncate the text on the shrinking side.
///
/// This is a shortcut for [`Self::wrap_mode`].
/// Does nothing if [`Self::extend`] is used (the default).
#[inline]
pub fn truncate(mut self) -> Self {
self.wrap_mode = Some(crate::TextWrapMode::Truncate);
self
}
/// Wrap the text on the shrinking side.
///
/// This is a shortcut for [`Self::wrap_mode`].
/// Does nothing if [`Self::extend`] is used (the default).
#[inline]
pub fn wrap(mut self) -> Self {
self.wrap_mode = Some(crate::TextWrapMode::Wrap);
self
}
pub fn show<RetL, RetR>( pub fn show<RetL, RetR>(
self, self,
ui: &mut Ui, ui: &mut Ui,
add_left: impl FnOnce(&mut Ui) -> RetL, add_left: impl FnOnce(&mut Ui) -> RetL,
add_right: impl FnOnce(&mut Ui) -> RetR, add_right: impl FnOnce(&mut Ui) -> RetR,
) -> (RetL, RetR) { ) -> (RetL, RetR) {
let Self { height, spacing } = self; let Self {
height,
spacing,
mut kind,
mut wrap_mode,
} = self;
let height = height.unwrap_or_else(|| ui.spacing().interact_size.y); let height = height.unwrap_or_else(|| ui.spacing().interact_size.y);
let spacing = spacing.unwrap_or_else(|| ui.spacing().item_spacing.x); let spacing = spacing.unwrap_or_else(|| ui.spacing().item_spacing.x);
let mut top_rect = ui.available_rect_before_wrap(); let mut top_rect = ui.available_rect_before_wrap();
top_rect.max.y = top_rect.min.y + height; top_rect.max.y = top_rect.min.y + height;
let result_left; if ui.is_sizing_pass() {
let result_right; kind = SidesKind::Extend;
wrap_mode = None;
}
let left_rect = { match kind {
let left_max_rect = top_rect; SidesKind::ShrinkLeft => {
let mut left_ui = ui.new_child( let (right_rect, result_right) = Self::create_ui(
UiBuilder::new() ui,
.max_rect(left_max_rect) top_rect,
.layout(Layout::left_to_right(Align::Center)), Layout::right_to_left(Align::Center),
add_right,
None,
);
let available_width = top_rect.width() - right_rect.width() - spacing;
let left_rect_constraint =
top_rect.with_max_x(top_rect.min.x + available_width.at_least(0.0));
let (left_rect, result_left) = Self::create_ui(
ui,
left_rect_constraint,
Layout::left_to_right(Align::Center),
add_left,
wrap_mode,
); );
result_left = add_left(&mut left_ui);
left_ui.min_rect()
};
let right_rect = { ui.advance_cursor_after_rect(left_rect.union(right_rect));
(result_left, result_right)
}
SidesKind::ShrinkRight => {
let (left_rect, result_left) = Self::create_ui(
ui,
top_rect,
Layout::left_to_right(Align::Center),
add_left,
None,
);
let right_rect_constraint = top_rect.with_min_x(left_rect.max.x + spacing);
let (right_rect, result_right) = Self::create_ui(
ui,
right_rect_constraint,
Layout::right_to_left(Align::Center),
add_right,
wrap_mode,
);
ui.advance_cursor_after_rect(left_rect.union(right_rect));
(result_left, result_right)
}
SidesKind::Extend => {
let (left_rect, result_left) = Self::create_ui(
ui,
top_rect,
Layout::left_to_right(Align::Center),
add_left,
None,
);
let right_max_rect = top_rect.with_min_x(left_rect.max.x); let right_max_rect = top_rect.with_min_x(left_rect.max.x);
let mut right_ui = ui.new_child( let (right_rect, result_right) = Self::create_ui(
UiBuilder::new() ui,
.max_rect(right_max_rect) right_max_rect,
.layout(Layout::right_to_left(Align::Center)), Layout::right_to_left(Align::Center),
add_right,
None,
); );
result_right = add_right(&mut right_ui);
right_ui.min_rect()
};
let mut final_rect = left_rect.union(right_rect); let mut final_rect = left_rect.union(right_rect);
let min_width = left_rect.width() + spacing + right_rect.width(); let min_width = left_rect.width() + spacing + right_rect.width();
if ui.is_sizing_pass() { if ui.is_sizing_pass() {
// Make as small as possible:
final_rect.max.x = left_rect.min.x + min_width; final_rect.max.x = left_rect.min.x + min_width;
} else { } else {
// If the rects overlap, make sure we expand the allocated rect so that the parent
// ui knows we overflowed, and resizes:
final_rect.max.x = final_rect.max.x.max(left_rect.min.x + min_width); final_rect.max.x = final_rect.max.x.max(left_rect.min.x + min_width);
} }
ui.advance_cursor_after_rect(final_rect); ui.advance_cursor_after_rect(final_rect);
(result_left, result_right) (result_left, result_right)
} }
} }
}
fn create_ui<Ret>(
ui: &mut Ui,
max_rect: emath::Rect,
layout: Layout,
add_content: impl FnOnce(&mut Ui) -> Ret,
wrap_mode: Option<crate::TextWrapMode>,
) -> (emath::Rect, Ret) {
let mut child_ui = ui.new_child(UiBuilder::new().max_rect(max_rect).layout(layout));
if let Some(wrap_mode) = wrap_mode {
child_ui.style_mut().wrap_mode = Some(wrap_mode);
}
let result = add_content(&mut child_ui);
(child_ui.min_rect(), result)
}
}

View File

@@ -78,7 +78,7 @@ impl Default for WrappedTextureManager {
// Will be filled in later // Will be filled in later
let font_id = tex_mngr.alloc( let font_id = tex_mngr.alloc(
"egui_font_texture".into(), "egui_font_texture".into(),
epaint::FontImage::new([0, 0]).into(), epaint::ColorImage::filled([0, 0], Color32::TRANSPARENT).into(),
Default::default(), Default::default(),
); );
assert_eq!( assert_eq!(
@@ -610,6 +610,8 @@ impl ContextImpl {
log::trace!("Adding new fonts"); log::trace!("Adding new fonts");
} }
let text_alpha_from_coverage = self.memory.options.style().visuals.text_alpha_from_coverage;
let mut is_new = false; let mut is_new = false;
let fonts = self let fonts = self
@@ -624,13 +626,14 @@ impl ContextImpl {
Fonts::new( Fonts::new(
pixels_per_point, pixels_per_point,
max_texture_side, max_texture_side,
text_alpha_from_coverage,
self.font_definitions.clone(), self.font_definitions.clone(),
) )
}); });
{ {
profiling::scope!("Fonts::begin_pass"); profiling::scope!("Fonts::begin_pass");
fonts.begin_pass(pixels_per_point, max_texture_side); fonts.begin_pass(pixels_per_point, max_texture_side, text_alpha_from_coverage);
} }
if is_new && self.memory.options.preload_font_glyphs { if is_new && self.memory.options.preload_font_glyphs {
@@ -1149,7 +1152,7 @@ impl Context {
ID clashes happens when things like Windows or CollapsingHeaders share names,\n\ ID clashes happens when things like Windows or CollapsingHeaders share names,\n\
or when things like Plot and Grid:s aren't given unique id_salt:s.\n\n\ or when things like Plot and Grid:s aren't given unique id_salt:s.\n\n\
Sometimes the solution is to use ui.push_id.", Sometimes the solution is to use ui.push_id.",
if below { "above" } else { "below" }) if below { "above" } else { "below" }),
); );
} }
} }
@@ -1216,6 +1219,51 @@ impl Context {
self.accesskit_node_builder(w.id, |builder| res.fill_accesskit_node_common(builder)); self.accesskit_node_builder(w.id, |builder| res.fill_accesskit_node_common(builder));
} }
#[cfg(feature = "accesskit")]
self.write(|ctx| {
use crate::{Align, pass_state::ScrollTarget, style::ScrollAnimation};
let viewport = ctx.viewport_for(ctx.viewport_id());
viewport
.input
.consume_accesskit_action_requests(res.id, |request| {
// TODO(lucasmerlin): Correctly handle the scroll unit:
// https://github.com/AccessKit/accesskit/blob/e639c0e0d8ccbfd9dff302d972fa06f9766d608e/common/src/lib.rs#L2621
const DISTANCE: f32 = 100.0;
match &request.action {
accesskit::Action::ScrollIntoView => {
viewport.this_pass.scroll_target = [
Some(ScrollTarget::new(
res.rect.x_range(),
Some(Align::Center),
ScrollAnimation::none(),
)),
Some(ScrollTarget::new(
res.rect.y_range(),
Some(Align::Center),
ScrollAnimation::none(),
)),
];
}
accesskit::Action::ScrollDown => {
viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::UP;
}
accesskit::Action::ScrollUp => {
viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::DOWN;
}
accesskit::Action::ScrollLeft => {
viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::LEFT;
}
accesskit::Action::ScrollRight => {
viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::RIGHT;
}
_ => return false,
};
true
});
});
res res
} }
@@ -1876,16 +1924,6 @@ impl Context {
} }
} }
pub(crate) fn reset_font_atlas(&self) {
let pixels_per_point = self.pixels_per_point();
let fonts = self.read(|ctx| {
ctx.fonts
.get(&pixels_per_point.into())
.map(|current_fonts| current_fonts.lock().fonts.definitions().clone())
});
self.memory_mut(|mem| mem.new_font_definitions = fonts);
}
/// Tell `egui` which fonts to use. /// Tell `egui` which fonts to use.
/// ///
/// The default `egui` fonts only support latin and cyrillic alphabets, /// The default `egui` fonts only support latin and cyrillic alphabets,
@@ -2021,19 +2059,10 @@ impl Context {
/// You can use [`Ui::style_mut`] to change the style of a single [`Ui`]. /// You can use [`Ui::style_mut`] to change the style of a single [`Ui`].
pub fn set_style_of(&self, theme: Theme, style: impl Into<Arc<Style>>) { pub fn set_style_of(&self, theme: Theme, style: impl Into<Arc<Style>>) {
let style = style.into(); let style = style.into();
let mut recreate_font_atlas = false; self.options_mut(|opt| match theme {
self.options_mut(|opt| { Theme::Dark => opt.dark_style = style,
let dest = match theme { Theme::Light => opt.light_style = style,
Theme::Dark => &mut opt.dark_style,
Theme::Light => &mut opt.light_style,
};
recreate_font_atlas =
dest.visuals.text_alpha_from_coverage != style.visuals.text_alpha_from_coverage;
*dest = style;
}); });
if recreate_font_atlas {
self.reset_font_atlas();
}
} }
/// The [`crate::Visuals`] used by all subsequent windows, panels etc. /// The [`crate::Visuals`] used by all subsequent windows, panels etc.
@@ -2430,28 +2459,7 @@ impl ContextImpl {
} }
// Inform the backend of all textures that have been updated (including font atlas). // Inform the backend of all textures that have been updated (including font atlas).
let textures_delta = { let textures_delta = self.tex_manager.0.write().take_delta();
// HACK to get much nicer looking text in light mode.
// This assumes all text is black-on-white in light mode,
// and white-on-black in dark mode, which is not necessarily true,
// but often close enough.
// Of course this fails for cases when there is black-on-white text in dark mode,
// and white-on-black text in light mode.
let text_alpha_from_coverage =
self.memory.options.style().visuals.text_alpha_from_coverage;
let mut textures_delta = self.tex_manager.0.write().take_delta();
for (_, delta) in &mut textures_delta.set {
if let ImageData::Font(font) = &mut delta.image {
delta.image =
ImageData::Color(font.to_color_image(text_alpha_from_coverage).into());
}
}
textures_delta
};
let mut platform_output: PlatformOutput = std::mem::take(&mut viewport.output); let mut platform_output: PlatformOutput = std::mem::take(&mut viewport.output);
@@ -3049,17 +3057,9 @@ impl Context {
options.ui(ui); options.ui(ui);
let text_alpha_from_coverage_changed =
prev_options.style().visuals.text_alpha_from_coverage
!= options.style().visuals.text_alpha_from_coverage;
if options != prev_options { if options != prev_options {
self.options_mut(move |o| *o = options); self.options_mut(move |o| *o = options);
} }
if text_alpha_from_coverage_changed {
ui.ctx().reset_font_atlas();
}
} }
fn fonts_tweak_ui(&self, ui: &mut Ui) { fn fonts_tweak_ui(&self, ui: &mut Ui) {

View File

@@ -91,6 +91,8 @@ pub fn hit_test(
} }
} }
close.retain(|rect| !rect.interact_rect.any_nan()); // Protect against bad input and transforms
// When using layer transforms it is common to stack layers close to each other. // When using layer transforms it is common to stack layers close to each other.
// For instance, you may have a resize-separator on a panel, with two // For instance, you may have a resize-separator on a panel, with two
// transform-layers on either side. // transform-layers on either side.

View File

@@ -824,6 +824,23 @@ impl InputState {
}) })
} }
#[cfg(feature = "accesskit")]
pub fn consume_accesskit_action_requests(
&mut self,
id: crate::Id,
mut consume: impl FnMut(&accesskit::ActionRequest) -> bool,
) {
let accesskit_id = id.accesskit_id();
self.events.retain(|event| {
if let Event::AccessKitActionRequest(request) = event {
if request.target == accesskit_id {
return !consume(request);
}
}
true
});
}
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
pub fn has_accesskit_action_request(&self, id: crate::Id, action: accesskit::Action) -> bool { pub fn has_accesskit_action_request(&self, id: crate::Id, action: accesskit::Action) -> bool {
self.accesskit_action_requests(id, action).next().is_some() self.accesskit_action_requests(id, action).next().is_some()
@@ -1448,8 +1465,11 @@ impl PointerState {
} }
if let Some(pos) = self.hover_pos() { if let Some(pos) = self.hover_pos() {
let dir = self.direction();
if dir != Vec2::ZERO {
return rect.intersects_ray(pos, self.direction()); return rect.intersects_ray(pos, self.direction());
} }
}
false false
} }
} }

View File

@@ -161,12 +161,10 @@
//! //!
//! * egui uses premultiplied alpha, so make sure your blending function is `(ONE, ONE_MINUS_SRC_ALPHA)`. //! * egui uses premultiplied alpha, so make sure your blending function is `(ONE, ONE_MINUS_SRC_ALPHA)`.
//! * Make sure your texture sampler is clamped (`GL_CLAMP_TO_EDGE`). //! * Make sure your texture sampler is clamped (`GL_CLAMP_TO_EDGE`).
//! * egui prefers linear color spaces for all blending so: //! * egui prefers gamma color spaces for all blending so:
//! * Use an sRGBA-aware texture if available (e.g. `GL_SRGB8_ALPHA8`). //! * Do NOT use an sRGBA-aware texture (NOT `GL_SRGB8_ALPHA8`).
//! * Otherwise: remember to decode gamma in the fragment shader. //! * Multiply texture and vertex colors in gamma space
//! * Decode the gamma of the incoming vertex colors in your vertex shader. //! * Turn OFF sRGBA/gamma framebuffer (NO `GL_FRAMEBUFFER_SRGB`).
//! * Turn on sRGBA/linear framebuffer if available (`GL_FRAMEBUFFER_SRGB`).
//! * Otherwise: gamma-encode the colors before you write them again.
//! //!
//! //!
//! # Understanding immediate mode //! # Understanding immediate mode
@@ -467,7 +465,7 @@ pub use emath::{
remap_clamp, vec2, remap_clamp, vec2,
}; };
pub use epaint::{ pub use epaint::{
ClippedPrimitive, ColorImage, CornerRadius, FontImage, ImageData, Margin, Mesh, PaintCallback, ClippedPrimitive, ColorImage, CornerRadius, ImageData, Margin, Mesh, PaintCallback,
PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId, mutex, PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId, mutex,
text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak}, text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak},
textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta}, textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta},

View File

@@ -27,7 +27,7 @@ use crate::{
vec2, widgets, vec2, widgets,
widgets::{ widgets::{
Button, Checkbox, DragValue, Hyperlink, Image, ImageSource, Label, Link, RadioButton, Button, Checkbox, DragValue, Hyperlink, Image, ImageSource, Label, Link, RadioButton,
SelectableLabel, Separator, Spinner, TextEdit, Widget, color_picker, Separator, Spinner, TextEdit, Widget, color_picker,
}, },
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -2077,13 +2077,13 @@ impl Ui {
Checkbox::new(checked, atoms).ui(self) Checkbox::new(checked, atoms).ui(self)
} }
/// Acts like a checkbox, but looks like a [`SelectableLabel`]. /// Acts like a checkbox, but looks like a [`Button::selectable`].
/// ///
/// Click to toggle to bool. /// Click to toggle to bool.
/// ///
/// See also [`Self::checkbox`]. /// See also [`Self::checkbox`].
pub fn toggle_value(&mut self, selected: &mut bool, text: impl Into<WidgetText>) -> Response { pub fn toggle_value<'a>(&mut self, selected: &mut bool, atoms: impl IntoAtoms<'a>) -> Response {
let mut response = self.selectable_label(*selected, text); let mut response = self.selectable_label(*selected, atoms);
if response.clicked() { if response.clicked() {
*selected = !*selected; *selected = !*selected;
response.mark_changed(); response.mark_changed();
@@ -2134,10 +2134,10 @@ impl Ui {
/// Show a label which can be selected or not. /// Show a label which can be selected or not.
/// ///
/// See also [`SelectableLabel`] and [`Self::toggle_value`]. /// See also [`Button::selectable`] and [`Self::toggle_value`].
#[must_use = "You should check if the user clicked this with `if ui.selectable_label(…).clicked() { … } "] #[must_use = "You should check if the user clicked this with `if ui.selectable_label(…).clicked() { … } "]
pub fn selectable_label(&mut self, checked: bool, text: impl Into<WidgetText>) -> Response { pub fn selectable_label<'a>(&mut self, checked: bool, text: impl IntoAtoms<'a>) -> Response {
SelectableLabel::new(checked, text).ui(self) Button::selectable(checked, text).ui(self)
} }
/// Show selectable text. It is selected if `*current_value == selected_value`. /// Show selectable text. It is selected if `*current_value == selected_value`.
@@ -2145,12 +2145,12 @@ impl Ui {
/// ///
/// Example: `ui.selectable_value(&mut my_enum, Enum::Alternative, "Alternative")`. /// Example: `ui.selectable_value(&mut my_enum, Enum::Alternative, "Alternative")`.
/// ///
/// See also [`SelectableLabel`] and [`Self::toggle_value`]. /// See also [`Button::selectable`] and [`Self::toggle_value`].
pub fn selectable_value<Value: PartialEq>( pub fn selectable_value<'a, Value: PartialEq>(
&mut self, &mut self,
current_value: &mut Value, current_value: &mut Value,
selected_value: Value, selected_value: Value,
text: impl Into<WidgetText>, text: impl IntoAtoms<'a>,
) -> Response { ) -> Response {
let mut response = self.selectable_label(*current_value == selected_value, text); let mut response = self.selectable_label(*current_value == selected_value, text);
if response.clicked() && *current_value != selected_value { if response.clicked() && *current_value != selected_value {

View File

@@ -29,6 +29,7 @@ pub struct Button<'a> {
stroke: Option<Stroke>, stroke: Option<Stroke>,
small: bool, small: bool,
frame: Option<bool>, frame: Option<bool>,
frame_when_inactive: bool,
min_size: Vec2, min_size: Vec2,
corner_radius: Option<CornerRadius>, corner_radius: Option<CornerRadius>,
selected: bool, selected: bool,
@@ -44,6 +45,7 @@ impl<'a> Button<'a> {
stroke: None, stroke: None,
small: false, small: false,
frame: None, frame: None,
frame_when_inactive: true,
min_size: Vec2::ZERO, min_size: Vec2::ZERO,
corner_radius: None, corner_radius: None,
selected: false, selected: false,
@@ -52,6 +54,27 @@ impl<'a> Button<'a> {
} }
} }
/// Show a selectable button.
///
/// Equivalent to:
/// ```rust
/// # use egui::{Button, IntoAtoms, __run_test_ui};
/// # __run_test_ui(|ui| {
/// let selected = true;
/// ui.add(Button::new("toggle me").selected(selected).frame_when_inactive(!selected).frame(true));
/// # });
/// ```
///
/// See also:
/// - [`Ui::selectable_value`]
/// - [`Ui::selectable_label`]
pub fn selectable(selected: bool, atoms: impl IntoAtoms<'a>) -> Self {
Self::new(atoms)
.selected(selected)
.frame_when_inactive(selected)
.frame(true)
}
/// Creates a button with an image. The size of the image as displayed is defined by the provided size. /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
/// ///
/// Note: In contrast to [`Button::new`], this limits the image size to the default font height /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
@@ -138,6 +161,18 @@ impl<'a> Button<'a> {
self self
} }
/// If `false`, the button will not have a frame when inactive.
///
/// Default: `true`.
///
/// Note: When [`Self::frame`] (or `ui.visuals().button_frame`) is `false`, this setting
/// has no effect.
#[inline]
pub fn frame_when_inactive(mut self, frame_when_inactive: bool) -> Self {
self.frame_when_inactive = frame_when_inactive;
self
}
/// By default, buttons senses clicks. /// By default, buttons senses clicks.
/// Change this to a drag-button with `Sense::drag()`. /// Change this to a drag-button with `Sense::drag()`.
#[inline] #[inline]
@@ -220,6 +255,7 @@ impl<'a> Button<'a> {
stroke, stroke,
small, small,
frame, frame,
frame_when_inactive,
mut min_size, mut min_size,
corner_radius, corner_radius,
selected, selected,
@@ -243,9 +279,9 @@ impl<'a> Button<'a> {
let text = layout.text().map(String::from); let text = layout.text().map(String::from);
let has_frame = frame.unwrap_or_else(|| ui.visuals().button_frame); let has_frame_margin = frame.unwrap_or_else(|| ui.visuals().button_frame);
let mut button_padding = if has_frame { let mut button_padding = if has_frame_margin {
ui.spacing().button_padding ui.spacing().button_padding
} else { } else {
Vec2::ZERO Vec2::ZERO
@@ -262,13 +298,22 @@ impl<'a> Button<'a> {
let response = if ui.is_rect_visible(prepared.response.rect) { let response = if ui.is_rect_visible(prepared.response.rect) {
let visuals = ui.style().interact_selectable(&prepared.response, selected); let visuals = ui.style().interact_selectable(&prepared.response, selected);
let visible_frame = if frame_when_inactive {
has_frame_margin
} else {
has_frame_margin
&& (prepared.response.hovered()
|| prepared.response.is_pointer_button_down_on()
|| prepared.response.has_focus())
};
if image_tint_follows_text_color { if image_tint_follows_text_color {
prepared.map_images(|image| image.tint(visuals.text_color())); prepared.map_images(|image| image.tint(visuals.text_color()));
} }
prepared.fallback_text_color = visuals.text_color(); prepared.fallback_text_color = visuals.text_color();
if has_frame { if visible_frame {
let stroke = stroke.unwrap_or(visuals.bg_stroke); let stroke = stroke.unwrap_or(visuals.bg_stroke);
let fill = fill.unwrap_or(visuals.weak_bg_fill); let fill = fill.unwrap_or(visuals.weak_bg_fill);
prepared.frame = prepared prepared.frame = prepared

View File

@@ -278,7 +278,8 @@ impl<'a> Image<'a> {
} }
/// Set alt text for the image. This will be shown when the image fails to load. /// Set alt text for the image. This will be shown when the image fails to load.
/// It will also be read to screen readers. ///
/// It will also be used for accessibility (e.g. read by screen readers).
#[inline] #[inline]
pub fn alt_text(mut self, label: impl Into<String>) -> Self { pub fn alt_text(mut self, label: impl Into<String>) -> Self {
self.alt_text = Some(label.into()); self.alt_text = Some(label.into());
@@ -672,7 +673,7 @@ pub fn paint_texture_load_result(
rect: Rect, rect: Rect,
show_loading_spinner: Option<bool>, show_loading_spinner: Option<bool>,
options: &ImageOptions, options: &ImageOptions,
alt: Option<&str>, alt_text: Option<&str>,
) { ) {
match tlr { match tlr {
Ok(TexturePoll::Ready { texture }) => { Ok(TexturePoll::Ready { texture }) => {
@@ -697,9 +698,9 @@ pub fn paint_texture_load_result(
0.0, 0.0,
TextFormat::simple(font_id.clone(), ui.visuals().error_fg_color), TextFormat::simple(font_id.clone(), ui.visuals().error_fg_color),
); );
if let Some(alt) = alt { if let Some(alt_text) = alt_text {
job.append( job.append(
alt, alt_text,
ui.spacing().item_spacing.x, ui.spacing().item_spacing.x,
TextFormat::simple(font_id, ui.visuals().text_color()), TextFormat::simple(font_id, ui.visuals().text_color()),
); );

View File

@@ -22,6 +22,8 @@ mod slider;
mod spinner; mod spinner;
pub mod text_edit; pub mod text_edit;
#[expect(deprecated)]
pub use self::selected_label::SelectableLabel;
pub use self::{ pub use self::{
button::Button, button::Button,
checkbox::Checkbox, checkbox::Checkbox,
@@ -35,7 +37,6 @@ pub use self::{
label::Label, label::Label,
progress_bar::ProgressBar, progress_bar::ProgressBar,
radio_button::RadioButton, radio_button::RadioButton,
selected_label::SelectableLabel,
separator::Separator, separator::Separator,
slider::{Slider, SliderClamping, SliderOrientation}, slider::{Slider, SliderClamping, SliderOrientation},
spinner::Spinner, spinner::Spinner,

View File

@@ -1,88 +1,13 @@
use crate::{ #![expect(deprecated, clippy::new_ret_no_self)]
NumExt as _, Response, Sense, TextStyle, Ui, Widget, WidgetInfo, WidgetText, WidgetType,
};
/// One out of several alternatives, either selected or not. use crate::WidgetText;
/// Will mark selected items with a different background color.
/// An alternative to [`crate::RadioButton`] and [`crate::Checkbox`]. #[deprecated = "Use `Button::selectable()` instead"]
/// pub struct SelectableLabel {}
/// Usually you'd use [`Ui::selectable_value`] or [`Ui::selectable_label`] instead.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// #[derive(PartialEq)]
/// enum Enum { First, Second, Third }
/// let mut my_enum = Enum::First;
///
/// ui.selectable_value(&mut my_enum, Enum::First, "First");
///
/// // is equivalent to:
///
/// if ui.add(egui::SelectableLabel::new(my_enum == Enum::First, "First")).clicked() {
/// my_enum = Enum::First
/// }
/// # });
/// ```
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct SelectableLabel {
selected: bool,
text: WidgetText,
}
impl SelectableLabel { impl SelectableLabel {
pub fn new(selected: bool, text: impl Into<WidgetText>) -> Self { #[deprecated = "Use `Button::selectable()` instead"]
Self { pub fn new(selected: bool, text: impl Into<WidgetText>) -> super::Button<'static> {
selected, crate::Button::selectable(selected, text)
text: text.into(),
}
}
}
impl Widget for SelectableLabel {
fn ui(self, ui: &mut Ui) -> Response {
let Self { selected, text } = self;
let button_padding = ui.spacing().button_padding;
let total_extra = button_padding + button_padding;
let wrap_width = ui.available_width() - total_extra.x;
let galley = text.into_galley(ui, None, wrap_width, TextStyle::Button);
let mut desired_size = total_extra + galley.size();
desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y);
let (rect, response) = ui.allocate_at_least(desired_size, Sense::click());
response.widget_info(|| {
WidgetInfo::selected(
WidgetType::SelectableLabel,
ui.is_enabled(),
selected,
galley.text(),
)
});
if ui.is_rect_visible(response.rect) {
let text_pos = ui
.layout()
.align_size_within_rect(galley.size(), rect.shrink2(button_padding))
.min;
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.corner_radius,
visuals.weak_bg_fill,
visuals.bg_stroke,
epaint::StrokeKind::Inside,
);
}
ui.painter().galley(text_pos, galley, visuals.text_color());
}
response
} }
} }

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f62d5375ff784e333e01a31b84d9caadf2dcbd2b19647a08977dab6550b48828 oid sha256:fc3dbdcd483d4da7a9c1a00f0245a7882997fbcd2d26f8d6a6d2d855f3382063
size 179654 size 179724

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0e37b3ce49c9ccc1a64beb58b176e23ab6c1fa2d897f676b0de85e510e6bfa85 oid sha256:c8ad2c2d494e2287b878049091688069e4d86b69ae72b89cb7ecbe47d8c35e33
size 100845 size 100766

View File

@@ -69,6 +69,6 @@ fn test_demo_app() {
// Can't use Harness::run because fractal clock keeps requesting repaints // Can't use Harness::run because fractal clock keeps requesting repaints
harness.run_steps(4); harness.run_steps(4);
results.add(harness.try_snapshot(&anchor.to_string())); results.add(harness.try_snapshot(anchor.to_string()));
} }
} }

View File

@@ -168,6 +168,7 @@ pub fn criterion_benchmark(c: &mut Criterion) {
let fonts = egui::epaint::text::Fonts::new( let fonts = egui::epaint::text::Fonts::new(
pixels_per_point, pixels_per_point,
max_texture_side, max_texture_side,
egui::epaint::AlphaFromCoverage::default(),
egui::FontDefinitions::default(), egui::FontDefinitions::default(),
); );
{ {
@@ -210,7 +211,11 @@ pub fn criterion_benchmark(c: &mut Criterion) {
let mut rng = rand::rng(); let mut rng = rand::rng();
b.iter(|| { b.iter(|| {
fonts.begin_pass(pixels_per_point, max_texture_side); fonts.begin_pass(
pixels_per_point,
max_texture_side,
egui::epaint::AlphaFromCoverage::default(),
);
// Delete a random character, simulating a user making an edit in a long file: // Delete a random character, simulating a user making an edit in a long file:
let mut new_string = string.clone(); let mut new_string = string.clone();

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -373,7 +373,7 @@ mod tests {
use crate::{Demo as _, demo::demo_app_windows::DemoGroups}; use crate::{Demo as _, demo::demo_app_windows::DemoGroups};
use egui_kittest::kittest::{NodeT as _, Queryable as _}; use egui_kittest::kittest::{NodeT as _, Queryable as _};
use egui_kittest::{Harness, SnapshotOptions, SnapshotResults}; use egui_kittest::{Harness, OsThreshold, SnapshotOptions, SnapshotResults};
#[test] #[test]
fn demos_should_match_snapshot() { fn demos_should_match_snapshot() {
@@ -410,12 +410,13 @@ mod tests {
harness.run_ok(); harness.run_ok();
let mut options = SnapshotOptions::default(); let mut options = SnapshotOptions::default();
// The Bézier Curve demo needs a threshold of 2.1 to pass on linux
if name == "Bézier Curve" { if name == "Bézier Curve" {
options.threshold = 2.1; // The Bézier Curve demo needs a threshold of 2.1 to pass on linux:
options = options.threshold(OsThreshold::new(0.0).linux(2.1));
} }
results.add(harness.try_snapshot_options(&format!("demos/{name}"), &options)); results.add(harness.try_snapshot_options(format!("demos/{name}"), &options));
} }
} }

View File

@@ -27,7 +27,7 @@ pub fn password_ui(ui: &mut egui::Ui, password: &mut String) -> egui::Response {
let result = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let result = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Toggle the `show_plaintext` bool with a button: // Toggle the `show_plaintext` bool with a button:
let response = ui let response = ui
.add(egui::SelectableLabel::new(show_plaintext, "👁")) .selectable_label(show_plaintext, "👁")
.on_hover_text("Show/hide password"); .on_hover_text("Show/hide password");
if response.clicked() { if response.clicked() {

View File

@@ -374,7 +374,7 @@ mod tests {
harness.fit_contents(); harness.fit_contents();
harness.run(); harness.run();
harness.snapshot(&format!("tessellation_test/{name}")); harness.snapshot(format!("tessellation_test/{name}"));
} }
} }
} }

View File

@@ -159,7 +159,7 @@ impl ColorTest {
ui.separator(); ui.separator();
// TODO(emilk): test color multiplication (image tint), // TODO(emilk): test color multiplication (image tint),
// to make sure vertex and texture color multiplication is done in linear space. // to make sure vertex and texture color multiplication is done in gamma space.
ui.label("Gamma interpolation:"); ui.label("Gamma interpolation:");
self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Gamma); self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Gamma);
@@ -191,8 +191,8 @@ impl ColorTest {
ui.separator(); ui.separator();
ui.label("Linear interpolation (texture sampling):"); ui.label("Texture interpolation (texture sampling) should be in gamma space:");
self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Linear); self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Gamma);
} }
fn show_gradients( fn show_gradients(
@@ -245,11 +245,10 @@ impl ColorTest {
let g = Gradient::endpoints(left, right); let g = Gradient::endpoints(left, right);
match interpolation { match interpolation {
Interpolation::Linear => { Interpolation::Linear => {}
// texture sampler is sRGBA aware, and should therefore be linear
self.tex_gradient(ui, "Texture of width 2 (test texture sampler)", bg_fill, &g);
}
Interpolation::Gamma => { Interpolation::Gamma => {
self.tex_gradient(ui, "Texture of width 2 (test texture sampler)", bg_fill, &g);
// vertex shader uses gamma // vertex shader uses gamma
self.vertex_gradient( self.vertex_gradient(
ui, ui,
@@ -330,7 +329,10 @@ fn vertex_gradient(ui: &mut Ui, bg_fill: Color32, gradient: &Gradient) -> Respon
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum Interpolation { enum Interpolation {
/// egui used to want Linear interpolation for some things, but now we're always in gamma space.
#[expect(unused)]
Linear, Linear,
Gamma, Gamma,
} }
@@ -745,7 +747,7 @@ mod tests {
harness.fit_contents(); harness.fit_contents();
results.add(harness.try_snapshot(&format!("rendering_test/dpi_{dpi:.2}"))); results.add(harness.try_snapshot(format!("rendering_test/dpi_{dpi:.2}")));
} }
} }
} }

View File

@@ -0,0 +1,25 @@
use egui::{hex_color, include_image};
use egui_kittest::Harness;
#[test]
fn test_image_blending() {
for pixels_per_point in [1.0, 2.0] {
let mut harness = Harness::builder()
.with_pixels_per_point(pixels_per_point)
.build_ui(|ui| {
egui_extras::install_image_loaders(ui.ctx());
egui::Frame::new()
.fill(hex_color!("#5981FF"))
.show(ui, |ui| {
ui.add(
egui::Image::new(include_image!("../data/ring.png"))
.max_height(18.0)
.tint(egui::Color32::GRAY),
);
});
});
harness.run();
harness.fit_contents();
harness.snapshot(format!("image_blending/image_x{pixels_per_point}"));
}
}

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cbe9f58cce2466360b4b93b03afaaee36711b3017ddff1b2b56bfe49ea91a076 oid sha256:13262df01a7f2cd5655b8b0bb9379ae02a851c877314375f047a7d749908125c
size 31306 size 31368

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b4f807098e0bc56eaacabb76d646a76036cc66a7a6e54b1c934fa9fecb5b0170 oid sha256:27d5aa7b7e6bd5f59c1765e98ca4588545284456e4cc255799ea797950e09850
size 26470 size 26461

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fdf3535530c1abb1262383ff9a3f2a740ad2c62ccec33ec5fb435be11625d139 oid sha256:aabc0e3821a2d9b21708e9b8d9be02ad55055ccabe719a93af921dba2384b4b3
size 35125 size 34297

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:16dc96246f011c6e9304409af7b4084f28e20cd813e44abca73834386e98b9b1 oid sha256:a3f8873c9cfb80ddeb1ccc0fa04c1c84ea936db1685238f5d29ee6e89f55e457
size 70373 size 68814

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e057c0bba4ec4c30e890c39153bd6dd17c511f410bfb894e66ef3ef9973d8fd4
size 807

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8b573f58a41efe26a0bf335e27cc123ffd4c13b24576e46d96ddedfed68b606
size 2027

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9f6cf5b14056522d06f0cb1e56bafd7e5ab7a9033eb358748d43d748bb0ceef1 oid sha256:39bd11647241521c0ad5c7163a1af4f1aa86792018657091a2d47bb7f2c48b47
size 553177 size 598408

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fd3bd1f64995db34a14dbc860ae8b8e269073ed7b8f10d10ce8f99b613cfc999 oid sha256:080a59163ab60d60738cfab5afac7cfbddfb585d8a0ee7d03540924b458badea
size 769357 size 833822

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f12e6145f3a1c3fda6dede3daeb0e52ed2bffb35531d823133224a477798a14a oid sha256:216d3d028f48f4bfbd6aca0a25500655d4eb4d5581a387397ed0b048d57fc0c3
size 907800 size 984737

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:05bdcfd2c34b6d7badede14f5495dce34e5e9cfe421314f40dcea15e9f865736 oid sha256:399fc3d64e7ff637425effe6c80d684be1cf8bb9b555458352d0ae6c62bddb5a
size 1024735 size 1109505

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8365c89f6b823f01464a9310bab7717bf25305b335cdeecf21711c7dca9f053f oid sha256:30ce4874a1adb8acf8c8e404f3e19e683eca3133cdef15befbc50d6c09618094
size 1140082 size 1241773

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b38021057ec6b5bb39c41bd4afaf5e9ff38687216d52d5bba8cbf7b6fdfe9a4f oid sha256:135fbe5f4ee485ee671931b64c16410b9073d40bedb23dc2545fc863448e8c63
size 1291518 size 1398091

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ac90da596084a880487035b276177e98d711854143373d59860f01733b1c0cd oid sha256:1b0fe7aa33506c59142aff764c6b229e8c55b35c8038312b22ea2659987a081a
size 45592 size 45578

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e412d424aac7b9cbdfdb8e36bd598e6cbc77183da7733c94c5f20e70699b8b4a oid sha256:3a3512ea7235640db79e86aa84039621784270201c9213c910f15e7451e5600b
size 87263 size 87336

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:222a32da21c69ee46e847e29fb05fd5e1d2de6bb7a22358549bc426f8243fdcb oid sha256:dc4918a534f26b72d42ef20221e26c0f91a0252870a1987c1fe4cc4aa3c74872
size 119671 size 119406

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d42e11f50a9522dd5ae73e8f8336bfb01493751705055a63abea3f5258f7c9c1 oid sha256:71182570a65693839fd7cd7390025731ab3f3f88ab55bc67d8be6466fe5a2c11
size 51626 size 51843

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c33617dfde24071fa65aff2543f22883f5526152fb344997b1877aeb38df72fe oid sha256:a0dc0294f990730da34fcbbc53f44280306ec6179826c20a6c9ee946e1148b61
size 54848 size 55042

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fbf40a1f56a6e280002719c6556fe477c93fa7fe88d398372ed36efaa1b83a62 oid sha256:3004adfe5a864bdc768ceb42d1f09b6debcaf5413f8fea4d36b0aff99e4584f9
size 55282 size 55511

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:33621731155ebb463fb01ea41ab20272885250efcd7d5c7683c10936b296e14d oid sha256:b99360833f59a212a965a13d52485ab8ad0e6420b9288b2d6936507067c22a85
size 36446 size 36395

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:186bd8a3146ad8f1977955e3f7fa593877ad1bf1e8376d32f446c67f36a2aafe oid sha256:82aa004f668f0ac6b493717b4bff8436ccc1e991c7fb3fcde5b5f3a123c06b9f
size 36493 size 36428

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:62df72fd7e2404c4aa482f09eff5103ee28e8afc42ee8c8c74307a246f64cda6 oid sha256:7e21bb01ae6e4226402a97b7086b49604cdde6b41a6770199df68dc940cd9a45
size 64651 size 64748

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f5a7397601cb718d5529842a428d2d328d4fe3d1a9cf1a3ca6d583d8525f75e oid sha256:0626bc45888ad250bf4b49c7f7f462a93ab91e3a2817fd7d0902411043c97132
size 153190 size 153289

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:34d85b6015112ea2733f7246f8daabfb9d983523e187339e4d26bfc1f3a3bba3 oid sha256:919a82c95468300bcd09471eb31d53d25d50cdcb02c27ddbc759d24e65da92b6
size 59460 size 59398

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4f51d75010cd1213daa6a1282d352655e64b69da7bca478011ea055a2e5349bc oid sha256:a55e39a640b0e2cc992286a86dcf38460f1abcc7b964df9022549ca1a94c4df5
size 146500 size 146408

View File

@@ -172,12 +172,7 @@ impl Painter {
let supported_extensions = gl.supported_extensions(); let supported_extensions = gl.supported_extensions();
log::trace!("OpenGL extensions: {supported_extensions:?}"); log::trace!("OpenGL extensions: {supported_extensions:?}");
let srgb_textures = shader_version == ShaderVersion::Es300 // WebGL2 always support sRGB let srgb_textures = false; // egui wants normal sRGB-unaware textures
|| supported_extensions.iter().any(|extension| {
// EXT_sRGB, GL_ARB_framebuffer_sRGB, GL_EXT_sRGB, GL_EXT_texture_sRGB_decode, …
extension.contains("sRGB")
});
log::debug!("SRGB texture Support: {:?}", srgb_textures);
let supports_srgb_framebuffer = !cfg!(target_arch = "wasm32") let supports_srgb_framebuffer = !cfg!(target_arch = "wasm32")
&& supported_extensions.iter().any(|extension| { && supported_extensions.iter().any(|extension| {
@@ -202,11 +197,10 @@ impl Painter {
&gl, &gl,
glow::FRAGMENT_SHADER, glow::FRAGMENT_SHADER,
&format!( &format!(
"{}\n#define NEW_SHADER_INTERFACE {}\n#define DITHERING {}\n#define SRGB_TEXTURES {}\n{}\n{}", "{}\n#define NEW_SHADER_INTERFACE {}\n#define DITHERING {}\n{}\n{}",
shader_version_declaration, shader_version_declaration,
shader_version.is_new_shader_interface() as i32, shader_version.is_new_shader_interface() as i32,
dithering as i32, dithering as i32,
srgb_textures as i32,
shader_prefix, shader_prefix,
FRAG_SRC FRAG_SRC
), ),
@@ -534,23 +528,6 @@ impl Painter {
self.upload_texture_srgb(delta.pos, image.size, delta.options, data); self.upload_texture_srgb(delta.pos, image.size, delta.options, data);
} }
egui::ImageData::Font(image) => {
assert_eq!(
image.width() * image.height(),
image.pixels.len(),
"Mismatch between texture size and texel count"
);
let data: Vec<u8> = {
profiling::scope!("font -> sRGBA");
image
.srgba_pixels(Default::default())
.flat_map(|a| a.to_array())
.collect()
};
self.upload_texture_srgb(delta.pos, image.size, delta.options, &data);
}
}; };
} }

View File

@@ -43,25 +43,8 @@ vec3 dither_interleaved(vec3 rgb, float levels) {
return rgb + noise / (levels - 1.0); return rgb + noise / (levels - 1.0);
} }
// 0-1 sRGB gamma from 0-1 linear
vec3 srgb_gamma_from_linear(vec3 rgb) {
bvec3 cutoff = lessThan(rgb, vec3(0.0031308));
vec3 lower = rgb * vec3(12.92);
vec3 higher = vec3(1.055) * pow(rgb, vec3(1.0 / 2.4)) - vec3(0.055);
return mix(higher, lower, vec3(cutoff));
}
// 0-1 sRGBA gamma from 0-1 linear
vec4 srgba_gamma_from_linear(vec4 rgba) {
return vec4(srgb_gamma_from_linear(rgba.rgb), rgba.a);
}
void main() { void main() {
#if SRGB_TEXTURES
vec4 texture_in_gamma = srgba_gamma_from_linear(texture2D(u_sampler, v_tc));
#else
vec4 texture_in_gamma = texture2D(u_sampler, v_tc); vec4 texture_in_gamma = texture2D(u_sampler, v_tc);
#endif
// We multiply the colors in gamma space, because that's the only way to get text to look right. // We multiply the colors in gamma space, because that's the only way to get text to look right.
vec4 frag_color_gamma = v_rgba_in_gamma * texture_in_gamma; vec4 frag_color_gamma = v_rgba_in_gamma * texture_in_gamma;

View File

@@ -28,6 +28,7 @@ pub use builder::*;
pub use node::*; pub use node::*;
pub use renderer::*; pub use renderer::*;
use egui::style::ScrollAnimation;
use egui::{Key, Modifiers, Pos2, Rect, RepaintCause, Vec2, ViewportId}; use egui::{Key, Modifiers, Pos2, Rect, RepaintCause, Vec2, ViewportId};
use kittest::Queryable; use kittest::Queryable;
@@ -55,6 +56,10 @@ impl Display for ExceededMaxStepsError {
/// The [Harness] has a optional generic state that can be used to pass data to the app / ui closure. /// The [Harness] has a optional generic state that can be used to pass data to the app / ui closure.
/// In _most cases_ it should be fine to just store the state in the closure itself. /// In _most cases_ it should be fine to just store the state in the closure itself.
/// The state functions are useful if you need to access the state after the harness has been created. /// The state functions are useful if you need to access the state after the harness has been created.
///
/// Some egui style options are changed from the defaults:
/// - The cursor blinking is disabled
/// - The scroll animation is disabled
pub struct Harness<'a, State = ()> { pub struct Harness<'a, State = ()> {
pub ctx: egui::Context, pub ctx: egui::Context,
input: egui::RawInput, input: egui::RawInput,
@@ -96,8 +101,12 @@ impl<'a, State> Harness<'a, State> {
let ctx = ctx.unwrap_or_default(); let ctx = ctx.unwrap_or_default();
ctx.set_theme(theme); ctx.set_theme(theme);
ctx.enable_accesskit(); ctx.enable_accesskit();
ctx.all_styles_mut(|style| {
// Disable cursor blinking so it doesn't interfere with snapshots // Disable cursor blinking so it doesn't interfere with snapshots
ctx.all_styles_mut(|style| style.visuals.text_cursor.blink = false); style.visuals.text_cursor.blink = false;
style.scroll_animation = ScrollAnimation::none();
style.animation_time = 0.0;
});
let mut input = egui::RawInput { let mut input = egui::RawInput {
screen_rect: Some(screen_rect), screen_rect: Some(screen_rect),
..Default::default() ..Default::default()
@@ -564,7 +573,8 @@ impl<'a, State> Harness<'a, State> {
.expect("Missing root viewport") .expect("Missing root viewport")
} }
fn root(&self) -> Node<'_> { /// The root node of the test harness.
pub fn root(&self) -> Node<'_> {
Node { Node {
accesskit_node: self.kittest.root(), accesskit_node: self.kittest.root(),
queue: &self.queued_events, queue: &self.queued_events,

View File

@@ -159,4 +159,49 @@ impl Node<'_> {
pub fn is_focused(&self) -> bool { pub fn is_focused(&self) -> bool {
self.accesskit_node.is_focused() self.accesskit_node.is_focused()
} }
/// Scroll the node into view.
pub fn scroll_to_me(&self) {
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
action: accesskit::Action::ScrollIntoView,
target: self.accesskit_node.id(),
data: None,
}));
}
/// Scroll the [`egui::ScrollArea`] containing this node down (100px).
pub fn scroll_down(&self) {
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
action: accesskit::Action::ScrollDown,
target: self.accesskit_node.id(),
data: None,
}));
}
/// Scroll the [`egui::ScrollArea`] containing this node up (100px).
pub fn scroll_up(&self) {
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
action: accesskit::Action::ScrollUp,
target: self.accesskit_node.id(),
data: None,
}));
}
/// Scroll the [`egui::ScrollArea`] containing this node left (100px).
pub fn scroll_left(&self) {
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
action: accesskit::Action::ScrollLeft,
target: self.accesskit_node.id(),
data: None,
}));
}
/// Scroll the [`egui::ScrollArea`] containing this node right (100px).
pub fn scroll_right(&self) {
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
action: accesskit::Action::ScrollRight,
target: self.accesskit_node.id(),
data: None,
}));
}
} }

View File

@@ -13,16 +13,117 @@ pub struct SnapshotOptions {
/// wgpu backends). /// wgpu backends).
pub threshold: f32, pub threshold: f32,
/// The number of pixels that can differ before the snapshot is considered a failure.
/// Preferably, you should use `threshold` to control the sensitivity of the image comparison.
/// As a last resort, you can use this to allow a certain number of pixels to differ.
/// If `None`, the default is `0` (meaning no pixels can differ).
/// If `Some`, the value can be set per OS
pub failed_pixel_count_threshold: usize,
/// The path where the snapshots will be saved. /// The path where the snapshots will be saved.
/// The default is `tests/snapshots`. /// The default is `tests/snapshots`.
pub output_path: PathBuf, pub output_path: PathBuf,
} }
/// Helper struct to define the number of pixels that can differ before the snapshot is considered a failure.
/// This is useful if you want to set different thresholds for different operating systems.
///
/// The default values are 0 / 0.0
///
/// Example usage:
/// ```no_run
/// use egui_kittest::{OsThreshold, SnapshotOptions};
/// let mut harness = egui_kittest::Harness::new_ui(|ui| {
/// ui.label("Hi!");
/// });
/// harness.snapshot_options(
/// "os_threshold_example",
/// &SnapshotOptions::new()
/// .threshold(OsThreshold::new(0.0).windows(10.0))
/// .failed_pixel_count_threshold(OsThreshold::new(0).windows(10).macos(53)
/// ))
/// ```
#[derive(Debug, Clone, Copy)]
pub struct OsThreshold<T> {
pub windows: T,
pub macos: T,
pub linux: T,
pub fallback: T,
}
impl From<usize> for OsThreshold<usize> {
fn from(value: usize) -> Self {
Self::new(value)
}
}
impl<T> OsThreshold<T>
where
T: Copy,
{
/// Use the same value for all
pub fn new(same: T) -> Self {
Self {
windows: same,
macos: same,
linux: same,
fallback: same,
}
}
/// Set the threshold for Windows.
#[inline]
pub fn windows(mut self, threshold: T) -> Self {
self.windows = threshold;
self
}
/// Set the threshold for macOS.
#[inline]
pub fn macos(mut self, threshold: T) -> Self {
self.macos = threshold;
self
}
/// Set the threshold for Linux.
#[inline]
pub fn linux(mut self, threshold: T) -> Self {
self.linux = threshold;
self
}
/// Get the threshold for the current operating system.
pub fn threshold(&self) -> T {
if cfg!(target_os = "windows") {
self.windows
} else if cfg!(target_os = "macos") {
self.macos
} else if cfg!(target_os = "linux") {
self.linux
} else {
self.fallback
}
}
}
impl From<OsThreshold<Self>> for usize {
fn from(threshold: OsThreshold<Self>) -> Self {
threshold.threshold()
}
}
impl From<OsThreshold<Self>> for f32 {
fn from(threshold: OsThreshold<Self>) -> Self {
threshold.threshold()
}
}
impl Default for SnapshotOptions { impl Default for SnapshotOptions {
fn default() -> Self { fn default() -> Self {
Self { Self {
threshold: 0.6, threshold: 0.6,
output_path: PathBuf::from("tests/snapshots"), output_path: PathBuf::from("tests/snapshots"),
failed_pixel_count_threshold: 0, // Default is 0, meaning no pixels can differ
} }
} }
} }
@@ -37,8 +138,8 @@ impl SnapshotOptions {
/// The default is `0.6` (which is enough for most egui tests to pass across different /// The default is `0.6` (which is enough for most egui tests to pass across different
/// wgpu backends). /// wgpu backends).
#[inline] #[inline]
pub fn threshold(mut self, threshold: f32) -> Self { pub fn threshold(mut self, threshold: impl Into<f32>) -> Self {
self.threshold = threshold; self.threshold = threshold.into();
self self
} }
@@ -49,6 +150,20 @@ impl SnapshotOptions {
self.output_path = output_path.into(); self.output_path = output_path.into();
self self
} }
/// Change the number of pixels that can differ before the snapshot is considered a failure.
///
/// Preferably, you should use [`Self::threshold`] to control the sensitivity of the image comparison.
/// As a last resort, you can use this to allow a certain number of pixels to differ.
#[inline]
pub fn failed_pixel_count_threshold(
mut self,
failed_pixel_count_threshold: impl Into<OsThreshold<usize>>,
) -> Self {
let failed_pixel_count_threshold = failed_pixel_count_threshold.into().threshold();
self.failed_pixel_count_threshold = failed_pixel_count_threshold;
self
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -58,7 +173,7 @@ pub enum SnapshotError {
/// Name of the test /// Name of the test
name: String, name: String,
/// Count of pixels that were different /// Count of pixels that were different (above the per-pixel threshold).
diff: i32, diff: i32,
/// Path where the diff image was saved /// Path where the diff image was saved
@@ -195,15 +310,24 @@ fn should_update_snapshots() -> bool {
/// reading or writing the snapshot. /// reading or writing the snapshot.
pub fn try_image_snapshot_options( pub fn try_image_snapshot_options(
new: &image::RgbaImage, new: &image::RgbaImage,
name: &str, name: impl Into<String>,
options: &SnapshotOptions,
) -> SnapshotResult {
try_image_snapshot_options_impl(new, name.into(), options)
}
fn try_image_snapshot_options_impl(
new: &image::RgbaImage,
name: String,
options: &SnapshotOptions, options: &SnapshotOptions,
) -> SnapshotResult { ) -> SnapshotResult {
let SnapshotOptions { let SnapshotOptions {
threshold, threshold,
output_path, output_path,
failed_pixel_count_threshold,
} = options; } = options;
let parent_path = if let Some(parent) = PathBuf::from(name).parent() { let parent_path = if let Some(parent) = PathBuf::from(&name).parent() {
output_path.join(parent) output_path.join(parent)
} else { } else {
output_path.clone() output_path.clone()
@@ -269,7 +393,7 @@ pub fn try_image_snapshot_options(
return update_snapshot(); return update_snapshot();
} else { } else {
return Err(SnapshotError::SizeMismatch { return Err(SnapshotError::SizeMismatch {
name: name.to_owned(), name,
expected: previous.dimensions(), expected: previous.dimensions(),
actual: new.dimensions(), actual: new.dimensions(),
}); });
@@ -280,19 +404,24 @@ pub fn try_image_snapshot_options(
let result = let result =
dify::diff::get_results(previous, new.clone(), *threshold, true, None, &None, &None); dify::diff::get_results(previous, new.clone(), *threshold, true, None, &None, &None);
if let Some((diff, result_image)) = result { if let Some((num_wrong_pixels, result_image)) = result {
result_image result_image
.save(diff_path.clone()) .save(diff_path.clone())
.map_err(|err| SnapshotError::WriteSnapshot { .map_err(|err| SnapshotError::WriteSnapshot {
path: diff_path.clone(), path: diff_path.clone(),
err, err,
})?; })?;
if should_update_snapshots() { if should_update_snapshots() {
update_snapshot() update_snapshot()
} else { } else {
if num_wrong_pixels as i64 <= *failed_pixel_count_threshold as i64 {
return Ok(());
}
Err(SnapshotError::Diff { Err(SnapshotError::Diff {
name: name.to_owned(), name,
diff, diff: num_wrong_pixels,
diff_path, diff_path,
}) })
} }
@@ -314,7 +443,7 @@ pub fn try_image_snapshot_options(
/// # Errors /// # Errors
/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error /// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error
/// reading or writing the snapshot. /// reading or writing the snapshot.
pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> SnapshotResult { pub fn try_image_snapshot(current: &image::RgbaImage, name: impl Into<String>) -> SnapshotResult {
try_image_snapshot_options(current, name, &SnapshotOptions::default()) try_image_snapshot_options(current, name, &SnapshotOptions::default())
} }
@@ -335,7 +464,11 @@ pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> SnapshotRes
/// Panics if the image does not match the snapshot or if there was an error reading or writing the /// Panics if the image does not match the snapshot or if there was an error reading or writing the
/// snapshot. /// snapshot.
#[track_caller] #[track_caller]
pub fn image_snapshot_options(current: &image::RgbaImage, name: &str, options: &SnapshotOptions) { pub fn image_snapshot_options(
current: &image::RgbaImage,
name: impl Into<String>,
options: &SnapshotOptions,
) {
match try_image_snapshot_options(current, name, options) { match try_image_snapshot_options(current, name, options) {
Ok(_) => {} Ok(_) => {}
Err(err) => { Err(err) => {
@@ -354,7 +487,7 @@ pub fn image_snapshot_options(current: &image::RgbaImage, name: &str, options: &
/// Panics if the image does not match the snapshot or if there was an error reading or writing the /// Panics if the image does not match the snapshot or if there was an error reading or writing the
/// snapshot. /// snapshot.
#[track_caller] #[track_caller]
pub fn image_snapshot(current: &image::RgbaImage, name: &str) { pub fn image_snapshot(current: &image::RgbaImage, name: impl Into<String>) {
match try_image_snapshot(current, name) { match try_image_snapshot(current, name) {
Ok(_) => {} Ok(_) => {}
Err(err) => { Err(err) => {
@@ -385,13 +518,13 @@ impl<State> Harness<'_, State> {
/// error reading or writing the snapshot, if the rendering fails or if no default renderer is available. /// error reading or writing the snapshot, if the rendering fails or if no default renderer is available.
pub fn try_snapshot_options( pub fn try_snapshot_options(
&mut self, &mut self,
name: &str, name: impl Into<String>,
options: &SnapshotOptions, options: &SnapshotOptions,
) -> SnapshotResult { ) -> SnapshotResult {
let image = self let image = self
.render() .render()
.map_err(|err| SnapshotError::RenderError { err })?; .map_err(|err| SnapshotError::RenderError { err })?;
try_image_snapshot_options(&image, name, options) try_image_snapshot_options(&image, name.into(), options)
} }
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot. /// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot.
@@ -402,7 +535,7 @@ impl<State> Harness<'_, State> {
/// # Errors /// # Errors
/// Returns a [`SnapshotError`] if the image does not match the snapshot, if there was an /// Returns a [`SnapshotError`] if the image does not match the snapshot, if there was an
/// error reading or writing the snapshot, if the rendering fails or if no default renderer is available. /// error reading or writing the snapshot, if the rendering fails or if no default renderer is available.
pub fn try_snapshot(&mut self, name: &str) -> SnapshotResult { pub fn try_snapshot(&mut self, name: impl Into<String>) -> SnapshotResult {
let image = self let image = self
.render() .render()
.map_err(|err| SnapshotError::RenderError { err })?; .map_err(|err| SnapshotError::RenderError { err })?;
@@ -428,7 +561,7 @@ impl<State> Harness<'_, State> {
/// Panics if the image does not match the snapshot, if there was an error reading or writing the /// Panics if the image does not match the snapshot, if there was an error reading or writing the
/// snapshot, if the rendering fails or if no default renderer is available. /// snapshot, if the rendering fails or if no default renderer is available.
#[track_caller] #[track_caller]
pub fn snapshot_options(&mut self, name: &str, options: &SnapshotOptions) { pub fn snapshot_options(&mut self, name: impl Into<String>, options: &SnapshotOptions) {
match self.try_snapshot_options(name, options) { match self.try_snapshot_options(name, options) {
Ok(_) => {} Ok(_) => {}
Err(err) => { Err(err) => {
@@ -446,7 +579,7 @@ impl<State> Harness<'_, State> {
/// Panics if the image does not match the snapshot, if there was an error reading or writing the /// Panics if the image does not match the snapshot, if there was an error reading or writing the
/// snapshot, if the rendering fails or if no default renderer is available. /// snapshot, if the rendering fails or if no default renderer is available.
#[track_caller] #[track_caller]
pub fn snapshot(&mut self, name: &str) { pub fn snapshot(&mut self, name: impl Into<String>) {
match self.try_snapshot(name) { match self.try_snapshot(name) {
Ok(_) => {} Ok(_) => {}
Err(err) => { Err(err) => {
@@ -467,7 +600,7 @@ impl<State> Harness<'_, State> {
)] )]
pub fn try_wgpu_snapshot_options( pub fn try_wgpu_snapshot_options(
&mut self, &mut self,
name: &str, name: impl Into<String>,
options: &SnapshotOptions, options: &SnapshotOptions,
) -> SnapshotResult { ) -> SnapshotResult {
self.try_snapshot_options(name, options) self.try_snapshot_options(name, options)
@@ -477,7 +610,7 @@ impl<State> Harness<'_, State> {
since = "0.31.0", since = "0.31.0",
note = "Use `try_snapshot` instead. This function will be removed in 0.32" note = "Use `try_snapshot` instead. This function will be removed in 0.32"
)] )]
pub fn try_wgpu_snapshot(&mut self, name: &str) -> SnapshotResult { pub fn try_wgpu_snapshot(&mut self, name: impl Into<String>) -> SnapshotResult {
self.try_snapshot(name) self.try_snapshot(name)
} }
@@ -485,7 +618,7 @@ impl<State> Harness<'_, State> {
since = "0.31.0", since = "0.31.0",
note = "Use `snapshot_options` instead. This function will be removed in 0.32" note = "Use `snapshot_options` instead. This function will be removed in 0.32"
)] )]
pub fn wgpu_snapshot_options(&mut self, name: &str, options: &SnapshotOptions) { pub fn wgpu_snapshot_options(&mut self, name: impl Into<String>, options: &SnapshotOptions) {
self.snapshot_options(name, options); self.snapshot_options(name, options);
} }

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70d76e55327de17163bc9c7e128c28153f95db3229dec919352a024eb80544f1
size 7399

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4b7b3145401b7cf9815a652a0914b230892ffda3b5e23fea530dafee9c0c3d3
size 8110

View File

@@ -1,5 +1,5 @@
use egui::{Modifiers, Vec2, include_image}; use egui::{Modifiers, ScrollArea, Vec2, include_image};
use egui_kittest::Harness; use egui_kittest::{Harness, SnapshotResults};
use kittest::Queryable as _; use kittest::Queryable as _;
#[test] #[test]
@@ -81,3 +81,60 @@ fn should_wait_for_images() {
harness.snapshot("should_wait_for_images"); harness.snapshot("should_wait_for_images");
} }
fn test_scroll_harness() -> Harness<'static, bool> {
Harness::builder()
.with_size(Vec2::new(100.0, 200.0))
.build_ui_state(
|ui, state| {
ScrollArea::vertical().show(ui, |ui| {
for i in 0..20 {
ui.label(format!("Item {i}"));
}
if ui.button("Hidden Button").clicked() {
*state = true;
};
});
},
false,
)
}
#[test]
fn test_scroll_to_me() {
let mut harness = test_scroll_harness();
let mut results = SnapshotResults::new();
results.add(harness.try_snapshot("test_scroll_initial"));
harness.get_by_label("Hidden Button").scroll_to_me();
harness.run();
results.add(harness.try_snapshot("test_scroll_scrolled"));
harness.get_by_label("Hidden Button").click();
harness.run();
assert!(
harness.state(),
"The button was not clicked after scrolling."
);
}
#[test]
fn test_scroll_down() {
let mut harness = test_scroll_harness();
let button = harness.get_by_label("Hidden Button");
button.scroll_down();
button.scroll_down();
harness.run();
harness.get_by_label("Hidden Button").click();
harness.run();
assert!(
harness.state(),
"The button was not clicked after scrolling down. (Probably not scrolled enough / at all)"
);
}

View File

@@ -146,7 +146,7 @@ impl Align {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Two-dimension alignment, e.g. [`Align2::LEFT_TOP`]. /// Two-dimension alignment, e.g. [`Align2::LEFT_TOP`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Align2(pub [Align; 2]); pub struct Align2(pub [Align; 2]);
@@ -298,3 +298,9 @@ impl std::ops::IndexMut<usize> for Align2 {
pub fn center_size_in_rect(size: Vec2, frame: Rect) -> Rect { pub fn center_size_in_rect(size: Vec2, frame: Rect) -> Rect {
Align2::CENTER_CENTER.align_size_within_rect(size, frame) Align2::CENTER_CENTER.align_size_within_rect(size, frame)
} }
impl std::fmt::Debug for Align2 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Align2({:?}, {:?})", self.x(), self.y())
}
}

View File

@@ -1,6 +1,6 @@
use std::fmt; use std::fmt;
use crate::{Div, Mul, Pos2, Rangef, Rot2, Vec2, lerp, pos2, vec2}; use crate::{Div, Mul, NumExt as _, Pos2, Rangef, Rot2, Vec2, lerp, pos2, vec2};
/// A rectangular region of space. /// A rectangular region of space.
/// ///
@@ -341,11 +341,13 @@ impl Rect {
self.max - self.min self.max - self.min
} }
/// Note: this can be negative.
#[inline(always)] #[inline(always)]
pub fn width(&self) -> f32 { pub fn width(&self) -> f32 {
self.max.x - self.min.x self.max.x - self.min.x
} }
/// Note: this can be negative.
#[inline(always)] #[inline(always)]
pub fn height(&self) -> f32 { pub fn height(&self) -> f32 {
self.max.y - self.min.y self.max.y - self.min.y
@@ -373,9 +375,10 @@ impl Rect {
} }
} }
/// This is never negative, and instead returns zero for negative rectangles.
#[inline(always)] #[inline(always)]
pub fn area(&self) -> f32 { pub fn area(&self) -> f32 {
self.width() * self.height() self.width().at_least(0.0) * self.height().at_least(0.0)
} }
/// The distance from the rect to the position. /// The distance from the rect to the position.
@@ -651,7 +654,7 @@ impl Rect {
pub fn intersects_ray(&self, o: Pos2, d: Vec2) -> bool { pub fn intersects_ray(&self, o: Pos2, d: Vec2) -> bool {
debug_assert!( debug_assert!(
d.is_normalized(), d.is_normalized(),
"expected normalized direction, but `d` has length {}", "Debug assert: expected normalized direction, but `d` has length {}",
d.length() d.length()
); );

View File

@@ -235,45 +235,34 @@ impl RectAlign {
[self.flip_x(), self.flip_y(), self.flip()] [self.flip_x(), self.flip_y(), self.flip()]
} }
/// Look for the [`RectAlign`] that fits best in the available space. /// Look for the first alternative [`RectAlign`] that allows the child rect to fit
/// inside the `screen_rect`.
///
/// If no alternative fits, the first is returned.
/// If no alternatives are given, `None` is returned.
/// ///
/// See also: /// See also:
/// - [`RectAlign::symmetries`] to calculate alternatives /// - [`RectAlign::symmetries`] to calculate alternatives
/// - [`RectAlign::MENU_ALIGNS`] for the 12 common menu positions /// - [`RectAlign::MENU_ALIGNS`] for the 12 common menu positions
pub fn find_best_align( pub fn find_best_align(
mut values_to_try: impl Iterator<Item = Self>, values_to_try: impl Iterator<Item = Self>,
available_space: Rect, screen_rect: Rect,
parent_rect: Rect, parent_rect: Rect,
gap: f32, gap: f32,
size: Vec2, expected_size: Vec2,
) -> Self { ) -> Option<Self> {
let area = size.x * size.y; let mut first_choice = None;
let blocked_area = |pos: Self| {
let rect = pos.align_rect(&parent_rect, size, gap);
area - available_space.intersect(rect).area()
};
let first = values_to_try.next().unwrap_or_default();
if blocked_area(first) == 0.0 {
return first;
}
let mut best_area = blocked_area(first);
let mut best = first;
for align in values_to_try { for align in values_to_try {
let blocked = blocked_area(align); first_choice = first_choice.or(Some(align)); // Remember the first alternative
if blocked == 0.0 {
return align; let suggested_popup_rect = align.align_rect(&parent_rect, expected_size, gap);
}
if blocked < best_area { if screen_rect.contains_rect(suggested_popup_rect) {
best = align; return Some(align);
best_area = blocked;
} }
} }
best first_choice
} }
} }

View File

@@ -1,8 +1,8 @@
use criterion::{Criterion, black_box, criterion_group, criterion_main}; use criterion::{Criterion, black_box, criterion_group, criterion_main};
use epaint::{ use epaint::{
ClippedShape, Color32, Mesh, PathStroke, Pos2, Rect, Shape, Stroke, TessellationOptions, AlphaFromCoverage, ClippedShape, Color32, Mesh, PathStroke, Pos2, Rect, Shape, Stroke,
Tessellator, TextureAtlas, Vec2, pos2, tessellator::Path, TessellationOptions, Tessellator, TextureAtlas, Vec2, pos2, tessellator::Path,
}; };
#[global_allocator] #[global_allocator]
@@ -66,7 +66,7 @@ fn tessellate_circles(c: &mut Criterion) {
let pixels_per_point = 2.0; let pixels_per_point = 2.0;
let options = TessellationOptions::default(); let options = TessellationOptions::default();
let atlas = TextureAtlas::new([4096, 256]); let atlas = TextureAtlas::new([4096, 256], AlphaFromCoverage::default());
let font_tex_size = atlas.size(); let font_tex_size = atlas.size();
let prepared_discs = atlas.prepared_discs(); let prepared_discs = atlas.prepared_discs();

View File

@@ -7,24 +7,20 @@ use std::sync::Arc;
/// ///
/// To load an image file, see [`ColorImage::from_rgba_unmultiplied`]. /// To load an image file, see [`ColorImage::from_rgba_unmultiplied`].
/// ///
/// In order to paint the image on screen, you first need to convert it to /// This is currently an enum with only one variant, but more image types may be added in the future.
/// ///
/// See also: [`ColorImage`], [`FontImage`]. /// See also: [`ColorImage`].
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum ImageData { pub enum ImageData {
/// RGBA image. /// RGBA image.
Color(Arc<ColorImage>), Color(Arc<ColorImage>),
/// Used for the font texture.
Font(FontImage),
} }
impl ImageData { impl ImageData {
pub fn size(&self) -> [usize; 2] { pub fn size(&self) -> [usize; 2] {
match self { match self {
Self::Color(image) => image.size, Self::Color(image) => image.size,
Self::Font(image) => image.size,
} }
} }
@@ -38,7 +34,7 @@ impl ImageData {
pub fn bytes_per_pixel(&self) -> usize { pub fn bytes_per_pixel(&self) -> usize {
match self { match self {
Self::Color(_) | Self::Font(_) => 4, Self::Color(_) => 4,
} }
} }
} }
@@ -271,6 +267,37 @@ impl ColorImage {
} }
Self::new([width, height], output) Self::new([width, height], output)
} }
/// Clone a sub-region as a new image.
pub fn region_by_pixels(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self {
assert!(
x + w <= self.width(),
"x + w should be <= self.width(), but x: {}, w: {}, width: {}",
x,
w,
self.width()
);
assert!(
y + h <= self.height(),
"y + h should be <= self.height(), but y: {}, h: {}, height: {}",
y,
h,
self.height()
);
let mut pixels = Vec::with_capacity(w * h);
for y in y..y + h {
let offset = y * self.width() + x;
pixels.extend(&self.pixels[offset..(offset + w)]);
}
assert_eq!(
pixels.len(),
w * h,
"pixels.len should be w * h, but got {}",
pixels.len()
);
Self::new([w, h], pixels)
}
} }
impl std::ops::Index<(usize, usize)> for ColorImage { impl std::ops::Index<(usize, usize)> for ColorImage {
@@ -371,127 +398,12 @@ impl AlphaFromCoverage {
} }
} }
/// A single-channel image designed for the font texture.
///
/// Each value represents "coverage", i.e. how much a texel is covered by a character.
///
/// This is roughly interpreted as the opacity of a white image.
#[derive(Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct FontImage {
/// width, height
pub size: [usize; 2],
/// The coverage value.
///
/// Often you want to use [`Self::srgba_pixels`] instead.
pub pixels: Vec<f32>,
}
impl FontImage {
pub fn new(size: [usize; 2]) -> Self {
Self {
size,
pixels: vec![0.0; size[0] * size[1]],
}
}
#[inline]
pub fn width(&self) -> usize {
self.size[0]
}
#[inline]
pub fn height(&self) -> usize {
self.size[1]
}
/// Returns the textures as `sRGBA` premultiplied pixels, row by row, top to bottom.
#[inline]
pub fn srgba_pixels(
&self,
alpha_from_coverage: AlphaFromCoverage,
) -> impl ExactSizeIterator<Item = Color32> + '_ {
self.pixels
.iter()
.map(move |&coverage| alpha_from_coverage.color_from_coverage(coverage))
}
/// Convert this coverage image to a [`ColorImage`].
pub fn to_color_image(&self, alpha_from_coverage: AlphaFromCoverage) -> ColorImage {
profiling::function_scope!();
let pixels = self.srgba_pixels(alpha_from_coverage).collect();
ColorImage::new(self.size, pixels)
}
/// Clone a sub-region as a new image.
pub fn region(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self {
assert!(
x + w <= self.width(),
"x + w should be <= self.width(), but x: {}, w: {}, width: {}",
x,
w,
self.width()
);
assert!(
y + h <= self.height(),
"y + h should be <= self.height(), but y: {}, h: {}, height: {}",
y,
h,
self.height()
);
let mut pixels = Vec::with_capacity(w * h);
for y in y..y + h {
let offset = y * self.width() + x;
pixels.extend(&self.pixels[offset..(offset + w)]);
}
assert_eq!(
pixels.len(),
w * h,
"pixels.len should be w * h, but got {}",
pixels.len()
);
Self {
size: [w, h],
pixels,
}
}
}
impl std::ops::Index<(usize, usize)> for FontImage {
type Output = f32;
#[inline]
fn index(&self, (x, y): (usize, usize)) -> &f32 {
let [w, h] = self.size;
assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}");
&self.pixels[y * w + x]
}
}
impl std::ops::IndexMut<(usize, usize)> for FontImage {
#[inline]
fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut f32 {
let [w, h] = self.size;
assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}");
&mut self.pixels[y * w + x]
}
}
impl From<FontImage> for ImageData {
#[inline(always)]
fn from(image: FontImage) -> Self {
Self::Font(image)
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// A change to an image. /// A change to an image.
/// ///
/// Either a whole new image, or an update to a rectangular region of it. /// Either a whole new image, or an update to a rectangular region of it.
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[must_use = "The painter must take care of this"] #[must_use = "The painter must take care of this"]
pub struct ImageDelta { pub struct ImageDelta {

View File

@@ -50,7 +50,7 @@ pub use self::{
color::ColorMode, color::ColorMode,
corner_radius::CornerRadius, corner_radius::CornerRadius,
corner_radius_f32::CornerRadiusF32, corner_radius_f32::CornerRadiusF32,
image::{AlphaFromCoverage, ColorImage, FontImage, ImageData, ImageDelta}, image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta},
margin::Margin, margin::Margin,
margin_f32::*, margin_f32::*,
mesh::{Mesh, Mesh16, Vertex}, mesh::{Mesh, Mesh16, Vertex},

View File

@@ -130,10 +130,12 @@ impl TextShape {
num_vertices: _, num_vertices: _,
num_indices: _, num_indices: _,
pixels_per_point: _, pixels_per_point: _,
intrinsic_size,
} = Arc::make_mut(galley); } = Arc::make_mut(galley);
*rect = transform.scaling * *rect; *rect = transform.scaling * *rect;
*mesh_bounds = transform.scaling * *mesh_bounds; *mesh_bounds = transform.scaling * *mesh_bounds;
*intrinsic_size = transform.scaling * *intrinsic_size;
for text::PlacedRow { pos, row } in rows { for text::PlacedRow { pos, row } in rows {
*pos *= transform.scaling; *pos *= transform.scaling;
@@ -179,7 +181,12 @@ mod tests {
#[test] #[test]
fn text_bounding_box_under_rotation() { fn text_bounding_box_under_rotation() {
let fonts = Fonts::new(1.0, 1024, FontDefinitions::default()); let fonts = Fonts::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let font = FontId::monospace(12.0); let font = FontId::monospace(12.0);
let mut t = crate::Shape::text( let mut t = crate::Shape::text(

View File

@@ -279,12 +279,13 @@ impl FontImpl {
} else { } else {
let glyph_pos = { let glyph_pos = {
let atlas = &mut self.atlas.lock(); let atlas = &mut self.atlas.lock();
let text_alpha_from_coverage = atlas.text_alpha_from_coverage;
let (glyph_pos, image) = atlas.allocate((glyph_width, glyph_height)); let (glyph_pos, image) = atlas.allocate((glyph_width, glyph_height));
glyph.draw(|x, y, v| { glyph.draw(|x, y, v| {
if 0.0 < v { if 0.0 < v {
let px = glyph_pos.0 + x as usize; let px = glyph_pos.0 + x as usize;
let py = glyph_pos.1 + y as usize; let py = glyph_pos.1 + y as usize;
image[(px, py)] = v; image[(px, py)] = text_alpha_from_coverage.color_from_coverage(v);
} }
}); });
glyph_pos glyph_pos

View File

@@ -1,7 +1,7 @@
use std::{collections::BTreeMap, sync::Arc}; use std::{collections::BTreeMap, sync::Arc};
use crate::{ use crate::{
TextureAtlas, AlphaFromCoverage, TextureAtlas,
mutex::{Mutex, MutexGuard}, mutex::{Mutex, MutexGuard},
text::{ text::{
Galley, LayoutJob, LayoutSection, Galley, LayoutJob, LayoutSection,
@@ -430,36 +430,56 @@ impl Fonts {
pub fn new( pub fn new(
pixels_per_point: f32, pixels_per_point: f32,
max_texture_side: usize, max_texture_side: usize,
text_alpha_from_coverage: AlphaFromCoverage,
definitions: FontDefinitions, definitions: FontDefinitions,
) -> Self { ) -> Self {
let fonts_and_cache = FontsAndCache { let fonts_and_cache = FontsAndCache {
fonts: FontsImpl::new(pixels_per_point, max_texture_side, definitions), fonts: FontsImpl::new(
pixels_per_point,
max_texture_side,
text_alpha_from_coverage,
definitions,
),
galley_cache: Default::default(), galley_cache: Default::default(),
}; };
Self(Arc::new(Mutex::new(fonts_and_cache))) Self(Arc::new(Mutex::new(fonts_and_cache)))
} }
/// Call at the start of each frame with the latest known /// Call at the start of each frame with the latest known
/// `pixels_per_point` and `max_texture_side`. /// `pixels_per_point`, `max_texture_side`, and `text_alpha_from_coverage`.
/// ///
/// Call after painting the previous frame, but before using [`Fonts`] for the new frame. /// Call after painting the previous frame, but before using [`Fonts`] for the new frame.
/// ///
/// This function will react to changes in `pixels_per_point` and `max_texture_side`, /// This function will react to changes in `pixels_per_point`, `max_texture_side`, and `text_alpha_from_coverage`,
/// as well as notice when the font atlas is getting full, and handle that. /// as well as notice when the font atlas is getting full, and handle that.
pub fn begin_pass(&self, pixels_per_point: f32, max_texture_side: usize) { pub fn begin_pass(
&self,
pixels_per_point: f32,
max_texture_side: usize,
text_alpha_from_coverage: AlphaFromCoverage,
) {
let mut fonts_and_cache = self.0.lock(); let mut fonts_and_cache = self.0.lock();
let pixels_per_point_changed = fonts_and_cache.fonts.pixels_per_point != pixels_per_point; let pixels_per_point_changed = fonts_and_cache.fonts.pixels_per_point != pixels_per_point;
let max_texture_side_changed = fonts_and_cache.fonts.max_texture_side != max_texture_side; let max_texture_side_changed = fonts_and_cache.fonts.max_texture_side != max_texture_side;
let text_alpha_from_coverage_changed =
fonts_and_cache.fonts.atlas.lock().text_alpha_from_coverage != text_alpha_from_coverage;
let font_atlas_almost_full = fonts_and_cache.fonts.atlas.lock().fill_ratio() > 0.8; let font_atlas_almost_full = fonts_and_cache.fonts.atlas.lock().fill_ratio() > 0.8;
let needs_recreate = let needs_recreate = pixels_per_point_changed
pixels_per_point_changed || max_texture_side_changed || font_atlas_almost_full; || max_texture_side_changed
|| text_alpha_from_coverage_changed
|| font_atlas_almost_full;
if needs_recreate { if needs_recreate {
let definitions = fonts_and_cache.fonts.definitions.clone(); let definitions = fonts_and_cache.fonts.definitions.clone();
*fonts_and_cache = FontsAndCache { *fonts_and_cache = FontsAndCache {
fonts: FontsImpl::new(pixels_per_point, max_texture_side, definitions), fonts: FontsImpl::new(
pixels_per_point,
max_texture_side,
text_alpha_from_coverage,
definitions,
),
galley_cache: Default::default(), galley_cache: Default::default(),
}; };
} }
@@ -497,7 +517,7 @@ impl Fonts {
/// The full font atlas image. /// The full font atlas image.
#[inline] #[inline]
pub fn image(&self) -> crate::FontImage { pub fn image(&self) -> crate::ColorImage {
self.lock().fonts.atlas.lock().image().clone() self.lock().fonts.atlas.lock().image().clone()
} }
@@ -642,6 +662,7 @@ impl FontsImpl {
pub fn new( pub fn new(
pixels_per_point: f32, pixels_per_point: f32,
max_texture_side: usize, max_texture_side: usize,
text_alpha_from_coverage: AlphaFromCoverage,
definitions: FontDefinitions, definitions: FontDefinitions,
) -> Self { ) -> Self {
assert!( assert!(
@@ -651,7 +672,7 @@ impl FontsImpl {
let texture_width = max_texture_side.at_most(16 * 1024); let texture_width = max_texture_side.at_most(16 * 1024);
let initial_height = 32; // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways. let initial_height = 32; // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways.
let atlas = TextureAtlas::new([texture_width, initial_height]); let atlas = TextureAtlas::new([texture_width, initial_height], text_alpha_from_coverage);
let atlas = Arc::new(Mutex::new(atlas)); let atlas = Arc::new(Mutex::new(atlas));
@@ -1051,6 +1072,7 @@ mod tests {
use core::f32; use core::f32;
use super::*; use super::*;
use crate::text::{TextWrapping, layout};
use crate::{Stroke, text::TextFormat}; use crate::{Stroke, text::TextFormat};
use ecolor::Color32; use ecolor::Color32;
use emath::Align; use emath::Align;
@@ -1120,6 +1142,7 @@ mod tests {
let mut fonts = FontsImpl::new( let mut fonts = FontsImpl::new(
pixels_per_point, pixels_per_point,
max_texture_side, max_texture_side,
AlphaFromCoverage::default(),
FontDefinitions::default(), FontDefinitions::default(),
); );
@@ -1161,4 +1184,60 @@ mod tests {
} }
} }
} }
#[test]
fn test_intrinsic_size() {
let pixels_per_point = [1.0, 1.3, 2.0, 0.867];
let max_widths = [40.0, 80.0, 133.0, 200.0];
let rounded_output_to_gui = [false, true];
for pixels_per_point in pixels_per_point {
let mut fonts = FontsImpl::new(
pixels_per_point,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
for &max_width in &max_widths {
for round_output_to_gui in rounded_output_to_gui {
for mut job in jobs() {
job.wrap = TextWrapping::wrap_at_width(max_width);
job.round_output_to_gui = round_output_to_gui;
let galley_wrapped = layout(&mut fonts, job.clone().into());
job.wrap = TextWrapping::no_max_width();
let text = job.text.clone();
let galley_unwrapped = layout(&mut fonts, job.into());
let intrinsic_size = galley_wrapped.intrinsic_size;
let unwrapped_size = galley_unwrapped.size();
let difference = (intrinsic_size - unwrapped_size).length().abs();
similar_asserts::assert_eq!(
format!("{intrinsic_size:.4?}"),
format!("{unwrapped_size:.4?}"),
"Wrapped intrinsic size should almost match unwrapped size. Intrinsic: {intrinsic_size:.8?} vs unwrapped: {unwrapped_size:.8?}
Difference: {difference:.8?}
wrapped rows: {}, unwrapped rows: {}
pixels_per_point: {pixels_per_point}, text: {text:?}, max_width: {max_width}, round_output_to_gui: {round_output_to_gui}",
galley_wrapped.rows.len(),
galley_unwrapped.rows.len()
);
similar_asserts::assert_eq!(
format!("{intrinsic_size:.4?}"),
format!("{unwrapped_size:.4?}"),
"Unwrapped galley intrinsic size should exactly match its size. \
{:.8?} vs {:8?}",
galley_unwrapped.intrinsic_size,
galley_unwrapped.size(),
);
}
}
}
}
}
} }

View File

@@ -82,6 +82,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
num_indices: 0, num_indices: 0,
pixels_per_point: fonts.pixels_per_point(), pixels_per_point: fonts.pixels_per_point(),
elided: true, elided: true,
intrinsic_size: Vec2::ZERO,
}; };
} }
@@ -94,6 +95,8 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
let point_scale = PointScale::new(fonts.pixels_per_point()); let point_scale = PointScale::new(fonts.pixels_per_point());
let intrinsic_size = calculate_intrinsic_size(point_scale, &job, &paragraphs);
let mut elided = false; let mut elided = false;
let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided); let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided);
if elided { if elided {
@@ -124,7 +127,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
} }
// Calculate the Y positions and tessellate the text: // Calculate the Y positions and tessellate the text:
galley_from_rows(point_scale, job, rows, elided) galley_from_rows(point_scale, job, rows, elided, intrinsic_size)
} }
// Ignores the Y coordinate. // Ignores the Y coordinate.
@@ -190,6 +193,46 @@ fn layout_section(
} }
} }
/// Calculate the intrinsic size of the text.
///
/// The result is eventually passed to `Response::intrinsic_size`.
/// This works by calculating the size of each `Paragraph` (instead of each `Row`).
fn calculate_intrinsic_size(
point_scale: PointScale,
job: &LayoutJob,
paragraphs: &[Paragraph],
) -> Vec2 {
let mut intrinsic_size = Vec2::ZERO;
for (idx, paragraph) in paragraphs.iter().enumerate() {
if paragraph.glyphs.is_empty() {
if idx == 0 {
intrinsic_size.y += point_scale.round_to_pixel(paragraph.empty_paragraph_height);
}
continue;
}
intrinsic_size.x = f32::max(
paragraph
.glyphs
.last()
.map(|l| l.max_x())
.unwrap_or_default(),
intrinsic_size.x,
);
let mut height = paragraph
.glyphs
.iter()
.map(|g| g.line_height)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(paragraph.empty_paragraph_height);
if idx == 0 {
height = f32::max(height, job.first_row_min_height);
}
intrinsic_size.y += point_scale.round_to_pixel(height);
}
intrinsic_size
}
// Ignores the Y coordinate. // Ignores the Y coordinate.
fn rows_from_paragraphs( fn rows_from_paragraphs(
paragraphs: Vec<Paragraph>, paragraphs: Vec<Paragraph>,
@@ -610,6 +653,7 @@ fn galley_from_rows(
job: Arc<LayoutJob>, job: Arc<LayoutJob>,
mut rows: Vec<PlacedRow>, mut rows: Vec<PlacedRow>,
elided: bool, elided: bool,
intrinsic_size: Vec2,
) -> Galley { ) -> Galley {
let mut first_row_min_height = job.first_row_min_height; let mut first_row_min_height = job.first_row_min_height;
let mut cursor_y = 0.0; let mut cursor_y = 0.0;
@@ -680,6 +724,7 @@ fn galley_from_rows(
num_vertices, num_vertices,
num_indices, num_indices,
pixels_per_point: point_scale.pixels_per_point, pixels_per_point: point_scale.pixels_per_point,
intrinsic_size,
}; };
if galley.job.round_output_to_gui { if galley.job.round_output_to_gui {
@@ -1034,11 +1079,18 @@ fn is_cjk_break_allowed(c: char) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::AlphaFromCoverage;
use super::{super::*, *}; use super::{super::*, *};
#[test] #[test]
fn test_zero_max_width() { fn test_zero_max_width() {
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default()); let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default());
layout_job.wrap.max_width = 0.0; layout_job.wrap.max_width = 0.0;
let galley = layout(&mut fonts, layout_job.into()); let galley = layout(&mut fonts, layout_job.into());
@@ -1049,7 +1101,12 @@ mod tests {
fn test_truncate_with_newline() { fn test_truncate_with_newline() {
// No matter where we wrap, we should be appending the newline character. // No matter where we wrap, we should be appending the newline character.
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let text_format = TextFormat { let text_format = TextFormat {
font_id: FontId::monospace(12.0), font_id: FontId::monospace(12.0),
..Default::default() ..Default::default()
@@ -1094,7 +1151,12 @@ mod tests {
#[test] #[test]
fn test_cjk() { fn test_cjk() {
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let mut layout_job = LayoutJob::single_section( let mut layout_job = LayoutJob::single_section(
"日本語とEnglishの混在した文章".into(), "日本語とEnglishの混在した文章".into(),
TextFormat::default(), TextFormat::default(),
@@ -1109,7 +1171,12 @@ mod tests {
#[test] #[test]
fn test_pre_cjk() { fn test_pre_cjk() {
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let mut layout_job = LayoutJob::single_section( let mut layout_job = LayoutJob::single_section(
"日本語とEnglishの混在した文章".into(), "日本語とEnglishの混在した文章".into(),
TextFormat::default(), TextFormat::default(),
@@ -1124,7 +1191,12 @@ mod tests {
#[test] #[test]
fn test_truncate_width() { fn test_truncate_width() {
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); let mut fonts = FontsImpl::new(
1.0,
1024,
AlphaFromCoverage::default(),
FontDefinitions::default(),
);
let mut layout_job = let mut layout_job =
LayoutJob::single_section("# DNA\nMore text".into(), TextFormat::default()); LayoutJob::single_section("# DNA\nMore text".into(), TextFormat::default());
layout_job.wrap.max_width = f32::INFINITY; layout_job.wrap.max_width = f32::INFINITY;

View File

@@ -560,6 +560,12 @@ pub struct Galley {
/// so that we can warn if this has changed once we get to /// so that we can warn if this has changed once we get to
/// tessellation. /// tessellation.
pub pixels_per_point: f32, pub pixels_per_point: f32,
/// This is the size that a non-wrapped, non-truncated, non-justified version of the text
/// would have.
///
/// Useful for advanced layouting.
pub intrinsic_size: Vec2,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -821,6 +827,8 @@ impl Galley {
.at_most(rect.min.x + self.job.wrap.max_width) .at_most(rect.min.x + self.job.wrap.max_width)
.floor_ui(); .floor_ui();
} }
self.intrinsic_size = self.intrinsic_size.round_ui();
} }
/// Append each galley under the previous one. /// Append each galley under the previous one.
@@ -836,6 +844,7 @@ impl Galley {
num_vertices: 0, num_vertices: 0,
num_indices: 0, num_indices: 0,
pixels_per_point, pixels_per_point,
intrinsic_size: Vec2::ZERO,
}; };
for (i, galley) in galleys.iter().enumerate() { for (i, galley) in galleys.iter().enumerate() {
@@ -872,6 +881,9 @@ impl Galley {
// Note that if `galley.elided` is true this will be the last `Galley` in // Note that if `galley.elided` is true this will be the last `Galley` in
// the vector and the loop will end. // the vector and the loop will end.
merged_galley.elided |= galley.elided; merged_galley.elided |= galley.elided;
merged_galley.intrinsic_size.x =
f32::max(merged_galley.intrinsic_size.x, galley.intrinsic_size.x);
merged_galley.intrinsic_size.y += galley.intrinsic_size.y;
} }
if merged_galley.job.round_output_to_gui { if merged_galley.job.round_output_to_gui {

View File

@@ -1,6 +1,7 @@
use ecolor::Color32;
use emath::{Rect, remap_clamp}; use emath::{Rect, remap_clamp};
use crate::{FontImage, ImageDelta}; use crate::{AlphaFromCoverage, ColorImage, ImageDelta};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Rectu { struct Rectu {
@@ -57,7 +58,7 @@ pub struct PreparedDisc {
/// More characters can be added, possibly expanding the texture. /// More characters can be added, possibly expanding the texture.
#[derive(Clone)] #[derive(Clone)]
pub struct TextureAtlas { pub struct TextureAtlas {
image: FontImage, image: ColorImage,
/// What part of the image that is dirty /// What part of the image that is dirty
dirty: Rectu, dirty: Rectu,
@@ -72,18 +73,22 @@ pub struct TextureAtlas {
/// pre-rasterized discs of radii `2^i`, where `i` is the index. /// pre-rasterized discs of radii `2^i`, where `i` is the index.
discs: Vec<PrerasterizedDisc>, discs: Vec<PrerasterizedDisc>,
/// Controls how to convert glyph coverage to alpha.
pub(crate) text_alpha_from_coverage: AlphaFromCoverage,
} }
impl TextureAtlas { impl TextureAtlas {
pub fn new(size: [usize; 2]) -> Self { pub fn new(size: [usize; 2], text_alpha_from_coverage: AlphaFromCoverage) -> Self {
assert!(size[0] >= 1024, "Tiny texture atlas"); assert!(size[0] >= 1024, "Tiny texture atlas");
let mut atlas = Self { let mut atlas = Self {
image: FontImage::new(size), image: ColorImage::filled(size, Color32::TRANSPARENT),
dirty: Rectu::EVERYTHING, dirty: Rectu::EVERYTHING,
cursor: (0, 0), cursor: (0, 0),
row_height: 0, row_height: 0,
overflowed: false, overflowed: false,
discs: vec![], // will be filled in below discs: vec![], // will be filled in below
text_alpha_from_coverage,
}; };
// Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color: // Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color:
@@ -93,7 +98,7 @@ impl TextureAtlas {
(0, 0), (0, 0),
"Expected the first allocation to be at (0, 0), but was at {pos:?}" "Expected the first allocation to be at (0, 0), but was at {pos:?}"
); );
image[pos] = 1.0; image[pos] = Color32::WHITE;
// Allocate a series of anti-aliased discs used to render small filled circles: // Allocate a series of anti-aliased discs used to render small filled circles:
// TODO(emilk): these circles can be packed A LOT better. // TODO(emilk): these circles can be packed A LOT better.
@@ -116,7 +121,7 @@ impl TextureAtlas {
let coverage = let coverage =
remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0);
image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] =
coverage; text_alpha_from_coverage.color_from_coverage(coverage);
} }
} }
atlas.discs.push(PrerasterizedDisc { atlas.discs.push(PrerasterizedDisc {
@@ -184,7 +189,7 @@ impl TextureAtlas {
/// The full font atlas image. /// The full font atlas image.
#[inline] #[inline]
pub fn image(&self) -> &FontImage { pub fn image(&self) -> &ColorImage {
&self.image &self.image
} }
@@ -200,14 +205,14 @@ impl TextureAtlas {
} else { } else {
let pos = [dirty.min_x, dirty.min_y]; let pos = [dirty.min_x, dirty.min_y];
let size = [dirty.max_x - dirty.min_x, dirty.max_y - dirty.min_y]; let size = [dirty.max_x - dirty.min_x, dirty.max_y - dirty.min_y];
let region = self.image.region(pos, size); let region = self.image.region_by_pixels(pos, size);
Some(ImageDelta::partial(pos, region, texture_options)) Some(ImageDelta::partial(pos, region, texture_options))
} }
} }
/// Returns the coordinates of where the rect ended up, /// Returns the coordinates of where the rect ended up,
/// and invalidates the region. /// and invalidates the region.
pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut FontImage) { pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut ColorImage) {
/// On some low-precision GPUs (my old iPad) characters get muddled up /// On some low-precision GPUs (my old iPad) characters get muddled up
/// if we don't add some empty pixels between the characters. /// if we don't add some empty pixels between the characters.
/// On modern high-precision GPUs this is not needed. /// On modern high-precision GPUs this is not needed.
@@ -254,13 +259,15 @@ impl TextureAtlas {
} }
} }
fn resize_to_min_height(image: &mut FontImage, required_height: usize) -> bool { fn resize_to_min_height(image: &mut ColorImage, required_height: usize) -> bool {
while required_height >= image.height() { while required_height >= image.height() {
image.size[1] *= 2; // double the height image.size[1] *= 2; // double the height
} }
if image.width() * image.height() > image.pixels.len() { if image.width() * image.height() > image.pixels.len() {
image.pixels.resize(image.width() * image.height(), 0.0); image
.pixels
.resize(image.width() * image.height(), Color32::TRANSPARENT);
true true
} else { } else {
false false

View File

@@ -271,7 +271,7 @@ pub enum TextureWrapMode {
/// What has been allocated and freed during the last period. /// What has been allocated and freed during the last period.
/// ///
/// These are commands given to the integration painter. /// These are commands given to the integration painter.
#[derive(Clone, Default, PartialEq)] #[derive(Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[must_use = "The painter must take care of this"] #[must_use = "The painter must take care of this"]
pub struct TexturesDelta { pub struct TexturesDelta {

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7ceaa95512c67dcbf1c8ba5a8f33bf4833c2e863d09903fb71b5aa2822cc086
size 7889

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442
size 8552

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f
size 1647

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f
size 1276

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88e1557dffa7295e7e7e37ed175fcec40aab939f9b67137a1ce33811e8ae4722
size 7148

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442
size 8552

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f
size 1647

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f
size 1276

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:508209ca303751ef323301b25bb3878410742ea79339b75363d2681b98d2712b
size 7068

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442
size 8552

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f
size 1647

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f
size 1276

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a0c9e39c18fc5bb1fc02a86dbf02e3ffca5537dbe8986d5c5b50cb4984c97466
size 9085

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442
size 8552

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f
size 1647

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f
size 1276

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6e9ba0acb573853ef5b3dedb1156d99cdf80338ccb160093960e8aaa41bd5df
size 9048

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:931af33f4548924b3bb75a2e513b9e689bce94436b10a9f811140eb11e9d6442
size 8552

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6448d44c1c9bed08cd6a4af39b141f3a4ca203ca5f7b967cbc0e0d0c2b4fb73f
size 1647

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44622002ebe287208d26359a06804b1f8737f86eb482322bdf3881a1fe53941f
size 1276

View File

@@ -69,3 +69,35 @@ fn single_test(name: &str, mut f: impl FnMut(&mut Ui)) -> SnapshotResult {
harness.try_snapshot(name) harness.try_snapshot(name)
} }
#[test]
fn test_intrinsic_size() {
let mut intrinsic_size = None;
for wrapping in [
TextWrapMode::Extend,
TextWrapMode::Wrap,
TextWrapMode::Truncate,
] {
_ = HarnessBuilder::default()
.with_size(Vec2::new(100.0, 100.0))
.build_ui(|ui| {
ui.style_mut().wrap_mode = Some(wrapping);
let response = ui.add(Button::new(
"Hello world this is a long text that should be wrapped.",
));
if let Some(current_intrinsic_size) = intrinsic_size {
assert_eq!(
Some(current_intrinsic_size),
response.intrinsic_size,
"For wrapping: {wrapping:?}"
);
}
assert!(
response.intrinsic_size.is_some(),
"intrinsic_size should be set for `Button`"
);
intrinsic_size = response.intrinsic_size;
});
}
assert_eq!(intrinsic_size.unwrap().round(), Vec2::new(305.0, 18.0));
}

View File

@@ -0,0 +1,76 @@
use egui::{TextWrapMode, Vec2, containers::Sides};
use egui_kittest::{Harness, SnapshotResults};
#[test]
fn sides_container_tests() {
let mut results = SnapshotResults::new();
test_variants("default", |sides| sides, &mut results);
test_variants(
"shrink_left",
|sides| sides.shrink_left().truncate(),
&mut results,
);
test_variants(
"shrink_right",
|sides| sides.shrink_right().truncate(),
&mut results,
);
test_variants(
"wrap_left",
|sides| sides.shrink_left().wrap_mode(TextWrapMode::Wrap),
&mut results,
);
test_variants(
"wrap_right",
|sides| sides.shrink_right().wrap_mode(TextWrapMode::Wrap),
&mut results,
);
}
fn test_variants(
name: &str,
mut create_sides: impl FnMut(Sides) -> Sides,
results: &mut SnapshotResults,
) {
for (variant_name, left_text, right_text, fit_contents) in [
("short", "Left", "Right", false),
(
"long",
"Very long left content that should not fit.",
"Very long right text that should also not fit.",
false,
),
("short_fit_contents", "Left", "Right", true),
(
"long_fit_contents",
"Very long left content that should not fit.",
"Very long right text that should also not fit.",
true,
),
] {
let mut harness = Harness::builder()
.with_size(Vec2::new(400.0, 50.0))
.build_ui(|ui| {
create_sides(Sides::new()).show(
ui,
|left| {
left.label(left_text);
},
|right| {
right.label(right_text);
},
);
});
if fit_contents {
harness.fit_contents();
}
results.add(harness.try_snapshot(format!("sides/{name}_{variant_name}")));
}
}

View File

@@ -244,7 +244,7 @@ fn test_widget_layout(name: &str, mut w: impl FnMut(&mut Ui) -> Response) -> Sna
}); });
harness.fit_contents(); harness.fit_contents();
harness.try_snapshot(&format!("layout/{name}")) harness.try_snapshot(format!("layout/{name}"))
} }
/// Utility to create a snapshot test of the different states of a egui widget. /// Utility to create a snapshot test of the different states of a egui widget.
@@ -370,7 +370,7 @@ impl<'a> VisualTests<'a> {
harness.fit_contents(); harness.fit_contents();
harness.try_snapshot(&format!("visuals/{}", self.name)) harness.try_snapshot(format!("visuals/{}", self.name))
} }
} }