1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-01 06:10:06 -04:00

Merge remote-tracking branch 'origin/main' into lucas/fix-macos-fullscreen

This commit is contained in:
lucasmerlin
2025-07-09 11:50:25 +02:00
133 changed files with 1836 additions and 732 deletions

View File

@@ -18,5 +18,133 @@ teselation = "tessellation"
tessalation = "tessellation" tessalation = "tessellation"
tesselation = "tessellation" tesselation = "tessellation"
# Use the more common spelling
adaptor = "adapter"
adaptors = "adapters"
# For consistency we prefer American English:
aeroplane = "airplane"
analogue = "analog"
analyse = "analyze"
appetiser = "appetizer"
arbour = "arbor"
ardour = "arbor"
armour = "armor"
artefact = "artifact"
authorise = "authorize"
behaviour = "behavior"
behavioural = "behavioral"
British = "American"
calibre = "caliber"
# cancelled = "canceled" # winit uses this :(
candour = "candor"
capitalise = "capitalize"
catalogue = "catalog"
centre = "center"
characterise = "characterize"
chequerboard = "checkerboard"
chequered = "checkered"
civilise = "civilize"
clamour = "clamor"
colonise = "colonize"
colour = "color"
coloured = "colored"
cosy = "cozy"
criticise = "criticize"
defence = "defense"
demeanour = "demeanor"
dialogue = "dialog"
distil = "distill"
doughnut = "donut"
dramatise = "dramatize"
draught = "draft"
emphasise = "emphasize"
endeavour = "endeavor"
enrol = "enroll"
epilogue = "epilog"
equalise = "equalize"
favour = "favor"
favourite = "favorite"
fibre = "fiber"
flavour = "flavor"
fulfil = "fufill"
gaol = "jail"
grey = "gray"
greys = "grays"
greyscale = "grayscale"
harbour = "habor"
honour = "honor"
humour = "humor"
instalment = "installment"
instil = "instill"
jewellery = "jewelry"
kerb = "curb"
labour = "labor"
litre = "liter"
lustre = "luster"
meagre = "meager"
metre = "meter"
mobilise = "mobilize"
monologue = "monolog"
naturalise = "naturalize"
neighbour = "neighbor"
neighbourhood = "neighborhood"
normalise = "normalize"
normalised = "normalized"
odour = "odor"
offence = "offense"
organise = "organize"
parlour = "parlor"
plough = "plow"
popularise = "popularize"
pretence = "pretense"
programme = "program"
prologue = "prolog"
rancour = "rancor"
realise = "realize"
recognise = "recognize"
recognised = "recognized"
rigour = "rigor"
rumour = "rumor"
sabre = "saber"
satirise = "satirize"
saviour = "savior"
savour = "savor"
sceptical = "skeptical"
sceptre = "scepter"
sepulchre = "sepulcher"
serialisation = "serialization"
serialise = "serialize"
serialised = "serialized"
skilful = "skillful"
sombre = "somber"
specialisation = "specialization"
specialise = "specialize"
specialised = "specialized"
splendour = "splendor"
standardise = "standardize"
sulphur = "sulfur"
symbolise = "symbolize"
theatre = "theater"
tonne = "ton"
travelogue = "travelog"
tumour = "tumor"
valour = "valor"
vaporise = "vaporize"
vigour = "vigor"
# null-terminated is the name of the wikipedia article!
# https://en.wikipedia.org/wiki/Null-terminated_string
nullterminated = "null-terminated"
zeroterminated = "null-terminated"
zero-terminated = "null-terminated"
[files] [files]
extend-exclude = ["web_demo/egui_demo_app.js"] # auto-generated extend-exclude = ["web_demo/egui_demo_app.js"] # auto-generated
[default]
extend-ignore-re = [
"#\\[doc\\(alias = .*", # We suggest "grey" in some doc
]

View File

@@ -36,7 +36,7 @@ There are snapshots test that might need to be updated.
Run the tests with `UPDATE_SNAPSHOTS=true cargo test --workspace --all-features` to update all of them. Run the tests with `UPDATE_SNAPSHOTS=true cargo test --workspace --all-features` to update all of them.
If CI keeps complaining about snapshots (which could happen if you don't use macOS, snapshots in CI are currently If CI keeps complaining about snapshots (which could happen if you don't use macOS, snapshots in CI are currently
rendered with macOS), you can instead run `./scripts/update_snapshots_from_ci.sh` to update your local snapshots from rendered with macOS), you can instead run `./scripts/update_snapshots_from_ci.sh` to update your local snapshots from
the last CI run of your PR (which will download the `test_results` artefact). the last CI run of your PR (which will download the `test_results` artifact).
For more info about the tests see [egui_kittest](./crates/egui_kittest/README.md). For more info about the tests see [egui_kittest](./crates/egui_kittest/README.md).
Snapshots and other big files are stored with git lfs. See [Working with git lfs](#working-with-git-lfs) for more info. Snapshots and other big files are stored with git lfs. See [Working with git lfs](#working-with-git-lfs) for more info.
If you see an `InvalidSignature` error when running snapshot tests, it's probably a problem related to git-lfs. If you see an `InvalidSignature` error when running snapshot tests, it's probably a problem related to git-lfs.

View File

@@ -72,7 +72,7 @@ fn roaming_appdata() -> Option<PathBuf> {
}; };
let path = if result == S_OK { let path = if result == S_OK {
// SAFETY: SHGetKnownFolderPath indicated success and is supposed to allocate a nullterminated string for us. // SAFETY: SHGetKnownFolderPath indicated success and is supposed to allocate a null-terminated string for us.
let path_slice = unsafe { slice::from_raw_parts(path_raw, wcslen(path_raw)) }; let path_slice = unsafe { slice::from_raw_parts(path_raw, wcslen(path_raw)) };
Some(PathBuf::from(OsString::from_wide(path_slice))) Some(PathBuf::from(OsString::from_wide(path_slice)))
} else { } else {

View File

@@ -3,8 +3,8 @@ use crate::web::string_from_js_value;
use super::{ use super::{
AppRunner, Closure, DEBUG_RESIZE, JsCast as _, JsValue, WebRunner, button_from_mouse_event, AppRunner, Closure, DEBUG_RESIZE, JsCast as _, JsValue, WebRunner, button_from_mouse_event,
location_hash, modifiers_from_kb_event, modifiers_from_mouse_event, modifiers_from_wheel_event, location_hash, modifiers_from_kb_event, modifiers_from_mouse_event, modifiers_from_wheel_event,
native_pixels_per_point, pos_from_mouse_event, prefers_color_scheme_dark, primary_touch_pos, native_pixels_per_point, pos_from_mouse_event, prefers_color_scheme, primary_touch_pos,
push_touches, text_from_keyboard_event, theme_from_dark_mode, translate_key, push_touches, text_from_keyboard_event, translate_key,
}; };
use web_sys::{Document, EventTarget, ShadowRoot}; use web_sys::{Document, EventTarget, ShadowRoot};
@@ -469,16 +469,19 @@ fn install_color_scheme_change_event(
runner_ref: &WebRunner, runner_ref: &WebRunner,
window: &web_sys::Window, window: &web_sys::Window,
) -> Result<(), JsValue> { ) -> Result<(), JsValue> {
if let Some(media_query_list) = prefers_color_scheme_dark(window)? { for theme in [egui::Theme::Dark, egui::Theme::Light] {
runner_ref.add_event_listener::<web_sys::MediaQueryListEvent>( if let Some(media_query_list) = prefers_color_scheme(window, theme)? {
&media_query_list, runner_ref.add_event_listener::<web_sys::MediaQueryListEvent>(
"change", &media_query_list,
|event, runner| { "change",
let theme = theme_from_dark_mode(event.matches()); |_event, runner| {
runner.input.raw.system_theme = Some(theme); if let Some(theme) = super::system_theme() {
runner.needs_repaint.repaint_asap(); runner.input.raw.system_theme = Some(theme);
}, runner.needs_repaint.repaint_asap();
)?; }
},
)?;
}
} }
Ok(()) Ok(())

View File

@@ -40,6 +40,7 @@ pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu;
pub use backend::*; pub use backend::*;
use egui::Theme;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Document, MediaQueryList, Node}; use web_sys::{Document, MediaQueryList, Node};
@@ -113,24 +114,31 @@ pub fn native_pixels_per_point() -> f32 {
/// ///
/// `None` means unknown. /// `None` means unknown.
pub fn system_theme() -> Option<egui::Theme> { pub fn system_theme() -> Option<egui::Theme> {
let dark_mode = prefers_color_scheme_dark(&web_sys::window()?) let window = web_sys::window()?;
.ok()?? if does_prefer_color_scheme(&window, Theme::Dark) == Some(true) {
.matches(); Some(Theme::Dark)
Some(theme_from_dark_mode(dark_mode)) } else if does_prefer_color_scheme(&window, Theme::Light) == Some(true) {
} Some(Theme::Light)
fn prefers_color_scheme_dark(window: &web_sys::Window) -> Result<Option<MediaQueryList>, JsValue> {
window.match_media("(prefers-color-scheme: dark)")
}
fn theme_from_dark_mode(dark_mode: bool) -> egui::Theme {
if dark_mode {
egui::Theme::Dark
} else { } else {
egui::Theme::Light None
} }
} }
fn does_prefer_color_scheme(window: &web_sys::Window, theme: Theme) -> Option<bool> {
Some(prefers_color_scheme(window, theme).ok()??.matches())
}
fn prefers_color_scheme(
window: &web_sys::Window,
theme: Theme,
) -> Result<Option<MediaQueryList>, JsValue> {
let theme = match theme {
Theme::Dark => "dark",
Theme::Light => "light",
};
window.match_media(format!("(prefers-color-scheme: {theme})").as_str())
}
/// Returns the canvas in client coordinates. /// Returns the canvas in client coordinates.
fn canvas_content_rect(canvas: &web_sys::HtmlCanvasElement) -> egui::Rect { fn canvas_content_rect(canvas: &web_sys::HtmlCanvasElement) -> egui::Rect {
let bounding_rect = canvas.get_bounding_client_rect(); let bounding_rect = canvas.get_bounding_client_rect();

View File

@@ -279,13 +279,6 @@ impl WebPainter for WebPainterWgpu {
Some((output_frame, capture_buffer)) Some((output_frame, capture_buffer))
}; };
{
let mut renderer = render_state.renderer.write();
for id in &textures_delta.free {
renderer.free_texture(id);
}
}
// Submit the commands: both the main buffer and user-defined ones. // Submit the commands: both the main buffer and user-defined ones.
render_state render_state
.queue .queue
@@ -307,6 +300,16 @@ impl WebPainter for WebPainterWgpu {
frame.present(); frame.present();
} }
// Free textures marked for destruction **after** queue submit since they might still be used in the current frame.
// Calling `wgpu::Texture::destroy` on a texture that is still in use would invalidate the command buffer(s) it is used in.
// However, once we called `wgpu::Queue::submit`, it is up for wgpu to determine how long the underlying gpu resource has to live.
{
let mut renderer = render_state.renderer.write();
for id in &textures_delta.free {
renderer.free_texture(id);
}
}
Ok(()) Ok(())
} }

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,15 +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(None).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());
@@ -638,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;
@@ -699,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,
@@ -747,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;
let result_right;
let left_rect = {
let left_max_rect = top_rect;
let mut left_ui = ui.new_child(
UiBuilder::new()
.max_rect(left_max_rect)
.layout(Layout::left_to_right(Align::Center)),
);
result_left = add_left(&mut left_ui);
left_ui.min_rect()
};
let right_rect = {
let right_max_rect = top_rect.with_min_x(left_rect.max.x);
let mut right_ui = ui.new_child(
UiBuilder::new()
.max_rect(right_max_rect)
.layout(Layout::right_to_left(Align::Center)),
);
result_right = add_right(&mut right_ui);
right_ui.min_rect()
};
let mut final_rect = left_rect.union(right_rect);
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: kind = SidesKind::Extend;
final_rect.max.x = left_rect.min.x + min_width; wrap_mode = None;
} 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);
} }
ui.advance_cursor_after_rect(final_rect); match kind {
SidesKind::ShrinkLeft => {
let (right_rect, result_right) = Self::create_ui(
ui,
top_rect,
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, result_right) 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_rect, result_right) = Self::create_ui(
ui,
right_max_rect,
Layout::right_to_left(Align::Center),
add_right,
None,
);
let mut final_rect = left_rect.union(right_rect);
let min_width = left_rect.width() + spacing + right_rect.width();
if ui.is_sizing_pass() {
final_rect.max.x = left_rect.min.x + min_width;
} else {
final_rect.max.x = final_rect.max.x.max(left_rect.min.x + min_width);
}
ui.advance_cursor_after_rect(final_rect);
(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
} }

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,7 +1465,10 @@ impl PointerState {
} }
if let Some(pos) = self.hover_pos() { if let Some(pos) = self.hover_pos() {
return rect.intersects_ray(pos, self.direction()); let dir = self.direction();
if dir != Vec2::ZERO {
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

@@ -408,11 +408,11 @@ impl Options {
.show(ui, |ui| { .show(ui, |ui| {
theme_preference.radio_buttons(ui); theme_preference.radio_buttons(ui);
std::sync::Arc::make_mut(match theme { let style = std::sync::Arc::make_mut(match theme {
Theme::Dark => dark_style, Theme::Dark => dark_style,
Theme::Light => light_style, Theme::Light => light_style,
}) });
.ui(ui); style.ui(ui);
}); });
CollapsingHeader::new("✒ Painting") CollapsingHeader::new("✒ Painting")

View File

@@ -89,9 +89,32 @@ impl ThemePreference {
/// Show radio-buttons to switch between light mode, dark mode and following the system theme. /// Show radio-buttons to switch between light mode, dark mode and following the system theme.
pub fn radio_buttons(&mut self, ui: &mut crate::Ui) { pub fn radio_buttons(&mut self, ui: &mut crate::Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.selectable_value(self, Self::Light, "☀ Light"); let system_theme = ui.ctx().input(|i| i.raw.system_theme);
ui.selectable_value(self, Self::Dark, "🌙 Dark");
ui.selectable_value(self, Self::System, "💻 System"); ui.selectable_value(self, Self::System, "💻 System")
.on_hover_ui(|ui| {
ui.label("Follow the system theme preference.");
ui.add_space(4.0);
if let Some(system_theme) = system_theme {
ui.label(format!(
"The current system theme is: {}",
match system_theme {
Theme::Dark => "dark",
Theme::Light => "light",
}
));
} else {
ui.label("The system theme is unknown.");
}
});
ui.selectable_value(self, Self::Dark, "🌙 Dark")
.on_hover_text("Use the dark mode theme");
ui.selectable_value(self, Self::Light, "☀ Light")
.on_hover_text("Use the light mode theme");
}); });
} }
} }

View File

@@ -83,6 +83,7 @@ impl Painter {
} }
/// If set, colors will be modified to look like this /// If set, colors will be modified to look like this
#[deprecated = "Use `multiply_opacity` instead"]
pub fn set_fade_to_color(&mut self, fade_to_color: Option<Color32>) { pub fn set_fade_to_color(&mut self, fade_to_color: Option<Color32>) {
self.fade_to_color = fade_to_color; self.fade_to_color = fade_to_color;
} }

View File

@@ -3,7 +3,7 @@
#![allow(clippy::if_same_then_else)] #![allow(clippy::if_same_then_else)]
use emath::Align; use emath::Align;
use epaint::{CornerRadius, Shadow, Stroke, text::FontTweak}; use epaint::{AlphaFromCoverage, CornerRadius, Shadow, Stroke, text::FontTweak};
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use crate::{ use crate::{
@@ -11,6 +11,7 @@ use crate::{
WidgetText, WidgetText,
ecolor::Color32, ecolor::Color32,
emath::{Rangef, Rect, Vec2, pos2, vec2}, emath::{Rangef, Rect, Vec2, pos2, vec2},
reset_button_with,
}; };
/// How to format numbers in e.g. a [`crate::DragValue`]. /// How to format numbers in e.g. a [`crate::DragValue`].
@@ -920,6 +921,9 @@ pub struct Visuals {
/// this is more to provide a convenient summary of the rest of the settings. /// this is more to provide a convenient summary of the rest of the settings.
pub dark_mode: bool, pub dark_mode: bool,
/// ADVANCED: Controls how we render text.
pub text_alpha_from_coverage: AlphaFromCoverage,
/// Override default text color for all text. /// Override default text color for all text.
/// ///
/// This is great for setting the color of text for any widget. /// This is great for setting the color of text for any widget.
@@ -935,6 +939,17 @@ pub struct Visuals {
/// it is disabled, non-interactive, hovered etc. /// it is disabled, non-interactive, hovered etc.
pub override_text_color: Option<Color32>, pub override_text_color: Option<Color32>,
/// How strong "weak" text is.
///
/// Ignored if [`Self::weak_text_color`] is set.
pub weak_text_alpha: f32,
/// Color of "weak" text.
///
/// If `None`, the color is [`Self::text_color`]
/// multiplied by [`Self::weak_text_alpha`].
pub weak_text_color: Option<Color32>,
/// Visual styles of widgets /// Visual styles of widgets
pub widgets: Widgets, pub widgets: Widgets,
@@ -952,6 +967,11 @@ pub struct Visuals {
/// that needs to look different from other interactive stuff. /// that needs to look different from other interactive stuff.
pub extreme_bg_color: Color32, pub extreme_bg_color: Color32,
/// The background color of [`crate::TextEdit`].
///
/// Defaults to [`Self::extreme_bg_color`].
pub text_edit_bg_color: Option<Color32>,
/// Background color behind code-styled monospaced labels. /// Background color behind code-styled monospaced labels.
pub code_bg_color: Color32, pub code_bg_color: Color32,
@@ -1019,6 +1039,9 @@ pub struct Visuals {
/// How to display numeric color values. /// How to display numeric color values.
pub numeric_color_space: NumericColorSpace, pub numeric_color_space: NumericColorSpace,
/// How much to modify the alpha of a disabled widget.
pub disabled_alpha: f32,
} }
impl Visuals { impl Visuals {
@@ -1034,7 +1057,8 @@ impl Visuals {
} }
pub fn weak_text_color(&self) -> Color32 { pub fn weak_text_color(&self) -> Color32 {
self.gray_out(self.text_color()) self.weak_text_color
.unwrap_or_else(|| self.text_color().gamma_multiply(self.weak_text_alpha))
} }
#[inline(always)] #[inline(always)]
@@ -1042,6 +1066,11 @@ impl Visuals {
self.widgets.active.text_color() self.widgets.active.text_color()
} }
/// The background color of [`crate::TextEdit`].
pub fn text_edit_bg_color(&self) -> Color32 {
self.text_edit_bg_color.unwrap_or(self.extreme_bg_color)
}
/// Window background color. /// Window background color.
#[inline(always)] #[inline(always)]
pub fn window_fill(&self) -> Color32 { pub fn window_fill(&self) -> Color32 {
@@ -1054,17 +1083,32 @@ impl Visuals {
} }
/// When fading out things, we fade the colors towards this. /// When fading out things, we fade the colors towards this.
// TODO(emilk): replace with an alpha
#[inline(always)] #[inline(always)]
#[deprecated = "Use disabled_alpha(). Fading is now handled by modifying the alpha channel."]
pub fn fade_out_to_color(&self) -> Color32 { pub fn fade_out_to_color(&self) -> Color32 {
self.widgets.noninteractive.weak_bg_fill self.widgets.noninteractive.weak_bg_fill
} }
/// Returned a "grayed out" version of the given color. /// Disabled widgets have their alpha modified by this.
#[inline(always)]
pub fn disabled_alpha(&self) -> f32 {
self.disabled_alpha
}
/// Returns a "disabled" version of the given color.
///
/// This function modifies the opcacity of the given color.
/// If this is undesirable use [`gray_out`](Self::gray_out).
#[inline(always)]
pub fn disable(&self, color: Color32) -> Color32 {
color.gamma_multiply(self.disabled_alpha())
}
/// Returns a "grayed out" version of the given color.
#[doc(alias = "grey_out")] #[doc(alias = "grey_out")]
#[inline(always)] #[inline(always)]
pub fn gray_out(&self, color: Color32) -> Color32 { pub fn gray_out(&self, color: Color32) -> Color32 {
crate::ecolor::tint_color_towards(color, self.fade_out_to_color()) crate::ecolor::tint_color_towards(color, self.widgets.noninteractive.weak_bg_fill)
} }
} }
@@ -1333,12 +1377,16 @@ impl Visuals {
pub fn dark() -> Self { pub fn dark() -> Self {
Self { Self {
dark_mode: true, dark_mode: true,
text_alpha_from_coverage: AlphaFromCoverage::DARK_MODE_DEFAULT,
override_text_color: None, override_text_color: None,
weak_text_alpha: 0.6,
weak_text_color: None,
widgets: Widgets::default(), widgets: Widgets::default(),
selection: Selection::default(), selection: Selection::default(),
hyperlink_color: Color32::from_rgb(90, 170, 255), hyperlink_color: Color32::from_rgb(90, 170, 255),
faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so
extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background
text_edit_bg_color: None, // use `extreme_bg_color` by default
code_bg_color: Color32::from_gray(64), code_bg_color: Color32::from_gray(64),
warn_fg_color: Color32::from_rgb(255, 143, 0), // orange warn_fg_color: Color32::from_rgb(255, 143, 0), // orange
error_fg_color: Color32::from_rgb(255, 0, 0), // red error_fg_color: Color32::from_rgb(255, 0, 0), // red
@@ -1384,6 +1432,7 @@ impl Visuals {
image_loading_spinners: true, image_loading_spinners: true,
numeric_color_space: NumericColorSpace::GammaByte, numeric_color_space: NumericColorSpace::GammaByte,
disabled_alpha: 0.5,
} }
} }
@@ -1391,6 +1440,7 @@ impl Visuals {
pub fn light() -> Self { pub fn light() -> Self {
Self { Self {
dark_mode: false, dark_mode: false,
text_alpha_from_coverage: AlphaFromCoverage::LIGHT_MODE_DEFAULT,
widgets: Widgets::light(), widgets: Widgets::light(),
selection: Selection::light(), selection: Selection::light(),
hyperlink_color: Color32::from_rgb(0, 155, 255), hyperlink_color: Color32::from_rgb(0, 155, 255),
@@ -1680,11 +1730,11 @@ impl Style {
ui.end_row(); ui.end_row();
}); });
ui.collapsing("🔠 Text Styles", |ui| text_styles_ui(ui, text_styles)); ui.collapsing("🔠 Text styles", |ui| text_styles_ui(ui, text_styles));
ui.collapsing("📏 Spacing", |ui| spacing.ui(ui)); ui.collapsing("📏 Spacing", |ui| spacing.ui(ui));
ui.collapsing("☝ Interaction", |ui| interaction.ui(ui)); ui.collapsing("☝ Interaction", |ui| interaction.ui(ui));
ui.collapsing("🎨 Visuals", |ui| visuals.ui(ui)); ui.collapsing("🎨 Visuals", |ui| visuals.ui(ui));
ui.collapsing("🔄 Scroll Animation", |ui| scroll_animation.ui(ui)); ui.collapsing("🔄 Scroll animation", |ui| scroll_animation.ui(ui));
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
ui.collapsing("🐛 Debug", |ui| debug.ui(ui)); ui.collapsing("🐛 Debug", |ui| debug.ui(ui));
@@ -2022,13 +2072,17 @@ impl WidgetVisuals {
impl Visuals { impl Visuals {
pub fn ui(&mut self, ui: &mut crate::Ui) { pub fn ui(&mut self, ui: &mut crate::Ui) {
let Self { let Self {
dark_mode: _, dark_mode,
text_alpha_from_coverage,
override_text_color: _, override_text_color: _,
weak_text_alpha,
weak_text_color,
widgets, widgets,
selection, selection,
hyperlink_color, hyperlink_color,
faint_bg_color, faint_bg_color,
extreme_bg_color, extreme_bg_color,
text_edit_bg_color,
code_bg_color, code_bg_color,
warn_fg_color, warn_fg_color,
error_fg_color, error_fg_color,
@@ -2063,44 +2117,115 @@ impl Visuals {
image_loading_spinners, image_loading_spinners,
numeric_color_space, numeric_color_space,
disabled_alpha,
} = self; } = self;
ui.collapsing("Background Colors", |ui| { fn ui_optional_color(
ui_color(ui, &mut widgets.inactive.weak_bg_fill, "Buttons"); ui: &mut Ui,
ui_color(ui, window_fill, "Windows"); color: &mut Option<Color32>,
ui_color(ui, panel_fill, "Panels"); default_value: Color32,
ui_color(ui, faint_bg_color, "Faint accent").on_hover_text( label: impl Into<WidgetText>,
"Used for faint accentuation of interactive things, like striped grids.", ) -> Response {
); let label_response = ui.label(label);
ui_color(ui, extreme_bg_color, "Extreme")
.on_hover_text("Background of plots and paintings"); ui.horizontal(|ui| {
let mut set = color.is_some();
ui.checkbox(&mut set, "");
if set {
let color = color.get_or_insert(default_value);
ui.color_edit_button_srgba(color);
} else {
*color = None;
};
});
ui.end_row();
label_response
}
ui.collapsing("Background colors", |ui| {
Grid::new("background_colors")
.num_columns(2)
.show(ui, |ui| {
fn ui_color(
ui: &mut Ui,
color: &mut Color32,
label: impl Into<WidgetText>,
) -> Response {
let label_response = ui.label(label);
ui.color_edit_button_srgba(color);
ui.end_row();
label_response
}
ui_color(ui, &mut widgets.inactive.weak_bg_fill, "Buttons");
ui_color(ui, window_fill, "Windows");
ui_color(ui, panel_fill, "Panels");
ui_color(ui, faint_bg_color, "Faint accent").on_hover_text(
"Used for faint accentuation of interactive things, like striped grids.",
);
ui_color(ui, extreme_bg_color, "Extreme")
.on_hover_text("Background of plots and paintings");
ui_optional_color(ui, text_edit_bg_color, *extreme_bg_color, "TextEdit")
.on_hover_text("Background of TextEdit");
});
}); });
ui.collapsing("Text color", |ui| { ui.collapsing("Text color", |ui| {
ui_text_color(ui, &mut widgets.noninteractive.fg_stroke.color, "Label"); fn ui_text_color(ui: &mut Ui, color: &mut Color32, label: impl Into<RichText>) {
ui_text_color( ui.label(label.into().color(*color));
ui, ui.color_edit_button_srgba(color);
&mut widgets.inactive.fg_stroke.color, ui.end_row();
"Unhovered button", }
);
ui_text_color(ui, &mut widgets.hovered.fg_stroke.color, "Hovered button");
ui_text_color(ui, &mut widgets.active.fg_stroke.color, "Clicked button");
ui_text_color(ui, warn_fg_color, RichText::new("Warnings")); Grid::new("text_color").num_columns(2).show(ui, |ui| {
ui_text_color(ui, error_fg_color, RichText::new("Errors")); ui_text_color(ui, &mut widgets.noninteractive.fg_stroke.color, "Label");
ui_text_color(ui, hyperlink_color, "hyperlink_color"); ui_text_color(
ui,
&mut widgets.inactive.fg_stroke.color,
"Unhovered button",
);
ui_text_color(ui, &mut widgets.hovered.fg_stroke.color, "Hovered button");
ui_text_color(ui, &mut widgets.active.fg_stroke.color, "Clicked button");
ui_color(ui, code_bg_color, RichText::new("Code background").code()).on_hover_ui( ui_text_color(ui, warn_fg_color, RichText::new("Warnings"));
|ui| { ui_text_color(ui, error_fg_color, RichText::new("Errors"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0; ui_text_color(ui, hyperlink_color, "hyperlink_color");
ui.label("For monospaced inlined text ");
ui.code("like this"); ui.label(RichText::new("Code background").code())
ui.label("."); .on_hover_ui(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("For monospaced inlined text ");
ui.code("like this");
ui.label(".");
});
}); });
}, ui.color_edit_button_srgba(code_bg_color);
); ui.end_row();
ui.label("Weak text alpha");
ui.add_enabled(
weak_text_color.is_none(),
DragValue::new(weak_text_alpha).speed(0.01).range(0.0..=1.0),
);
ui.end_row();
ui_optional_color(
ui,
weak_text_color,
widgets.noninteractive.text_color(),
"Weak text color",
);
});
ui.add_space(4.0);
text_alpha_from_coverage_ui(ui, text_alpha_from_coverage);
}); });
ui.collapsing("Text cursor", |ui| { ui.collapsing("Text cursor", |ui| {
@@ -2191,12 +2316,60 @@ impl Visuals {
ui.label("Color picker type"); ui.label("Color picker type");
numeric_color_space.toggle_button_ui(ui); numeric_color_space.toggle_button_ui(ui);
}); });
ui.add(Slider::new(disabled_alpha, 0.0..=1.0).text("Disabled element alpha"));
}); });
ui.vertical_centered(|ui| reset_button(ui, self, "Reset visuals")); let dark_mode = *dark_mode;
ui.vertical_centered(|ui| {
reset_button_with(
ui,
self,
"Reset visuals",
if dark_mode {
Self::dark()
} else {
Self::light()
},
);
});
} }
} }
fn text_alpha_from_coverage_ui(ui: &mut Ui, text_alpha_from_coverage: &mut AlphaFromCoverage) {
let mut dark_mode_special =
*text_alpha_from_coverage == AlphaFromCoverage::TwoCoverageMinusCoverageSq;
ui.horizontal(|ui| {
ui.label("Text rendering:");
ui.checkbox(&mut dark_mode_special, "Dark-mode special");
if dark_mode_special {
*text_alpha_from_coverage = AlphaFromCoverage::TwoCoverageMinusCoverageSq;
} else {
let mut gamma = match text_alpha_from_coverage {
AlphaFromCoverage::Linear => 1.0,
AlphaFromCoverage::Gamma(gamma) => *gamma,
AlphaFromCoverage::TwoCoverageMinusCoverageSq => 0.5, // approximately the same
};
ui.add(
DragValue::new(&mut gamma)
.speed(0.01)
.range(0.1..=4.0)
.prefix("Gamma: "),
);
if gamma == 1.0 {
*text_alpha_from_coverage = AlphaFromCoverage::Linear;
} else {
*text_alpha_from_coverage = AlphaFromCoverage::Gamma(gamma);
}
}
});
}
impl TextCursorStyle { impl TextCursorStyle {
fn ui(&mut self, ui: &mut Ui) { fn ui(&mut self, ui: &mut Ui) {
let Self { let Self {
@@ -2310,22 +2483,6 @@ fn two_drag_values(value: &mut Vec2, range: std::ops::RangeInclusive<f32>) -> im
} }
} }
fn ui_color(ui: &mut Ui, color: &mut Color32, label: impl Into<WidgetText>) -> Response {
ui.horizontal(|ui| {
ui.color_edit_button_srgba(color);
ui.label(label);
})
.response
}
fn ui_text_color(ui: &mut Ui, color: &mut Color32, label: impl Into<RichText>) -> Response {
ui.horizontal(|ui| {
ui.color_edit_button_srgba(color);
ui.label(label.into().color(*color));
})
.response
}
impl HandleShape { impl HandleShape {
pub fn ui(&mut self, ui: &mut Ui) { pub fn ui(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {

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,
}, },
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -522,7 +522,7 @@ impl Ui {
self.enabled = false; self.enabled = false;
if self.is_visible() { if self.is_visible() {
self.painter self.painter
.set_fade_to_color(Some(self.visuals().fade_out_to_color())); .multiply_opacity(self.visuals().disabled_alpha());
} }
} }
@@ -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 {
@@ -2963,8 +2963,8 @@ impl Ui {
if is_anything_being_dragged && !can_accept_what_is_being_dragged { if is_anything_being_dragged && !can_accept_what_is_being_dragged {
// When dragging something else, show that it can't be dropped here: // When dragging something else, show that it can't be dropped here:
fill = self.visuals().gray_out(fill); fill = self.visuals().disable(fill);
stroke.color = self.visuals().gray_out(stroke.color); stroke.color = self.visuals().disable(stroke.color);
} }
frame.frame.fill = fill; frame.frame.fill = fill;

View File

@@ -438,7 +438,7 @@ impl ViewportBuilder {
} }
/// macOS: Set to `true` to allow the window to be moved by dragging the background. /// macOS: Set to `true` to allow the window to be moved by dragging the background.
/// Enabling this feature can result in unexpected behaviour with draggable UI widgets such as sliders. /// Enabling this feature can result in unexpected behavior with draggable UI widgets such as sliders.
#[inline] #[inline]
pub fn with_movable_by_background(mut self, value: bool) -> Self { pub fn with_movable_by_background(mut self, value: bool) -> Self {
self.movable_by_window_background = Some(value); self.movable_by_window_background = Some(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

@@ -63,7 +63,7 @@ type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley
/// See [`TextEdit::show`]. /// See [`TextEdit::show`].
/// ///
/// ## Other /// ## Other
/// The background color of a [`crate::TextEdit`] is [`crate::Visuals::extreme_bg_color`] or can be set with [`crate::TextEdit::background_color`]. /// The background color of a [`crate::TextEdit`] is [`crate::Visuals::text_edit_bg_color`] or can be set with [`crate::TextEdit::background_color`].
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"] #[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct TextEdit<'t> { pub struct TextEdit<'t> {
text: &'t mut dyn TextBuffer, text: &'t mut dyn TextBuffer,
@@ -207,7 +207,7 @@ impl<'t> TextEdit<'t> {
self self
} }
/// Set the background color of the [`TextEdit`]. The default is [`crate::Visuals::extreme_bg_color`]. /// Set the background color of the [`TextEdit`]. The default is [`crate::Visuals::text_edit_bg_color`].
// TODO(bircni): remove this once #3284 is implemented // TODO(bircni): remove this once #3284 is implemented
#[inline] #[inline]
pub fn background_color(mut self, color: Color32) -> Self { pub fn background_color(mut self, color: Color32) -> Self {
@@ -428,7 +428,7 @@ impl TextEdit<'_> {
let where_to_put_background = ui.painter().add(Shape::Noop); let where_to_put_background = ui.painter().add(Shape::Noop);
let background_color = self let background_color = self
.background_color .background_color
.unwrap_or(ui.visuals().extreme_bg_color); .unwrap_or_else(|| ui.visuals().text_edit_bg_color());
let output = self.show_content(ui); let output = self.show_content(ui);
if frame { if frame {

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8cf6d0b20f127f22d49daefed27fc2d0ca43d645fe1486cf7f6fcbb676bdec82 oid sha256:fc3dbdcd483d4da7a9c1a00f0245a7882997fbcd2d26f8d6a6d2d855f3382063
size 179065 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

@@ -319,16 +319,27 @@ mod tests {
date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
..Default::default() ..Default::default()
}; };
let mut harness = Harness::builder()
.with_pixels_per_point(2.0)
.with_size(Vec2::new(380.0, 550.0))
.build_ui(|ui| {
egui_extras::install_image_loaders(ui.ctx());
demo.ui(ui);
});
harness.fit_contents(); for pixels_per_point in [1, 2] {
for theme in [egui::Theme::Light, egui::Theme::Dark] {
let mut harness = Harness::builder()
.with_pixels_per_point(pixels_per_point as f32)
.with_theme(theme)
.with_size(Vec2::new(380.0, 550.0))
.build_ui(|ui| {
egui_extras::install_image_loaders(ui.ctx());
demo.ui(ui);
});
harness.snapshot("widget_gallery"); harness.fit_contents();
let theme_name = match theme {
egui::Theme::Light => "light",
egui::Theme::Dark => "dark",
};
let image_name = format!("widget_gallery_{theme_name}_x{pixels_per_point}");
harness.snapshot(&image_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:a0b999914adab3d44c614bdf3b28abd268a4ff6162c5680b43035b3f71cb69bb oid sha256:6d5f3129e34e22b15245212904e0a3537a0c7e70f1d35fd3e9c784af707038b5
size 23999 size 24018

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c975c8b646425878b704f32198286010730746caf5d463ca8cbcfe539922816 oid sha256:5d05c74583024825d82f1fe8dbeb2a793e366016e87a639f51d46945831de82a
size 99087 size 99106

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5fc9e2ec3253a30ac9649995b019b6b23d745dba07a327886f574a15c0e99e84 oid sha256:e0a49139611dd5f4e97874e8f7b0e12b649da5f373ff7ee80a7ff678f7f8ecc7
size 50082 size 50321

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3110fab8444cb41dffe8b27277fa5dafd0d335aaf13dca511bcccc8b53fb25c8 oid sha256:17f7065c47712f140e4a9fd9eed61a7118fe12cd79cf0745642a02921eaa596b
size 24046 size 24065

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:df1e4a1e355100056713e751a8979d4201d0e4aab5513ba2f7a3e4852e1347dd oid sha256:cfc5dd77728ee0b3d319c5851698305851b6713eb054a6eb5b618e9670f58ae5
size 264340 size 277018

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cdff6256488f3a40c65a3d73c0635377bf661c57927bce4c853b2a5f3b33274e oid sha256:aabc0e3821a2d9b21708e9b8d9be02ad55055ccabe719a93af921dba2384b4b3
size 35121 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:4a347875ef98ebbd606774e03baffdb317cb0246882db116fee1aa7685efbb88 oid sha256:1bd15215f3ec1b365b8c51987f629d5653e4f40e84c34756aea0dc863af27c1e
size 179653 size 179906

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f0e3eeca8abb4fba632cef4621d478fb66af1a0f13e099dda9a79420cc2b6301 oid sha256:7e80bf8c79e6e431806c85385a0bd9262796efc0a1e74d431a1b896dde0b8651
size 115320 size 115338

View File

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

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c4d6a15094eee5d96a8af5c44ea9d0c962d650ee9b867344c86d1229e526dcb5 oid sha256:26e4828e42f54da24d032f384f8829e42bcebaee072923f277db582f84302911
size 12822 size 12847

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:02abc0cbab97e572218f422f4b167957869d4e2b4b388355444c20148d998015 oid sha256:4a4520aa68d6752992fd2f87090a317e6e5e24b5cdb5ee2e82daf07f9471ca80
size 35200 size 35251

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:b567d4038fd73986c80d2bd12197a6df037fde043545993fa9fe4160d0af446c oid sha256:a0dc0294f990730da34fcbbc53f44280306ec6179826c20a6c9ee946e1148b61
size 54829 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 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee129f0542f21e12f5aa3c2f9746e7cadd73441a04d580f57c12c1cdd40d8b07
size 153136

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,9 +100,9 @@ impl BytesLoader for FileLoader {
let entry = entry.get_mut(); let entry = entry.get_mut();
*entry = Poll::Ready(result); *entry = Poll::Ready(result);
ctx.request_repaint(); ctx.request_repaint();
log::trace!("finished loading {uri:?}"); log::trace!("Finished loading {uri:?}");
} else { } else {
log::trace!("cancelled loading {uri:?}\nNote: This can happen if `forget_image` is called while the image is still loading."); log::trace!("Canceled loading {uri:?}\nNote: This can happen if `forget_image` is called while the image is still loading.");
} }
} }
}) })

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(None)
.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

@@ -7,6 +7,7 @@ use std::marker::PhantomData;
pub struct HarnessBuilder<State = ()> { pub struct HarnessBuilder<State = ()> {
pub(crate) screen_rect: Rect, pub(crate) screen_rect: Rect,
pub(crate) pixels_per_point: f32, pub(crate) pixels_per_point: f32,
pub(crate) theme: egui::Theme,
pub(crate) max_steps: u64, pub(crate) max_steps: u64,
pub(crate) step_dt: f32, pub(crate) step_dt: f32,
pub(crate) state: PhantomData<State>, pub(crate) state: PhantomData<State>,
@@ -19,6 +20,7 @@ impl<State> Default for HarnessBuilder<State> {
Self { Self {
screen_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)), screen_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)),
pixels_per_point: 1.0, pixels_per_point: 1.0,
theme: egui::Theme::Dark,
state: PhantomData, state: PhantomData,
renderer: Box::new(LazyRenderer::default()), renderer: Box::new(LazyRenderer::default()),
max_steps: 4, max_steps: 4,
@@ -45,6 +47,13 @@ impl<State> HarnessBuilder<State> {
self self
} }
/// Set the desired theme (dark or light).
#[inline]
pub fn with_theme(mut self, theme: egui::Theme) -> Self {
self.theme = theme;
self
}
/// Set the maximum number of steps to run when calling [`Harness::run`]. /// Set the maximum number of steps to run when calling [`Harness::run`].
/// ///
/// Default is 4. /// Default is 4.

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,
@@ -86,6 +91,7 @@ impl<'a, State> Harness<'a, State> {
let HarnessBuilder { let HarnessBuilder {
screen_rect, screen_rect,
pixels_per_point, pixels_per_point,
theme,
max_steps, max_steps,
step_dt, step_dt,
state: _, state: _,
@@ -93,9 +99,14 @@ impl<'a, State> Harness<'a, State> {
wait_for_pending_images, wait_for_pending_images,
} = builder; } = builder;
let ctx = ctx.unwrap_or_default(); let ctx = ctx.unwrap_or_default();
ctx.set_theme(theme);
ctx.enable_accesskit(); ctx.enable_accesskit();
// Disable cursor blinking so it doesn't interfere with snapshots ctx.all_styles_mut(|style| {
ctx.all_styles_mut(|style| style.visuals.text_cursor.blink = false); // Disable cursor blinking so it doesn't interfere with snapshots
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()
@@ -562,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

@@ -7,9 +7,9 @@ use crate::{Align2, Pos2, Rect, Vec2};
/// ///
/// There are helper constants for the 12 common menu positions: /// There are helper constants for the 12 common menu positions:
/// ```text /// ```text
/// ┌───────────┐ ┌────────┐ ┌─────────┐ /// ┌───────────┐ ┌────────┐ ┌─────────┐
/// │ TOP_START │ │ TOP │ │ TOP_END │ /// │ TOP_START │ │ TOP │ │ TOP_END │
/// └───────────┘ └────────┘ └─────────┘ /// └───────────┘ └────────┘ └─────────┘
/// ┌──────────┐ ┌────────────────────────────────────┐ ┌───────────┐ /// ┌──────────┐ ┌────────────────────────────────────┐ ┌───────────┐
/// │LEFT_START│ │ │ │RIGHT_START│ /// │LEFT_START│ │ │ │RIGHT_START│
/// └──────────┘ │ │ └───────────┘ /// └──────────┘ │ │ └───────────┘
@@ -19,9 +19,9 @@ use crate::{Align2, Pos2, Rect, Vec2};
/// ┌──────────┐ │ │ ┌───────────┐ /// ┌──────────┐ │ │ ┌───────────┐
/// │ LEFT_END │ │ │ │ RIGHT_END │ /// │ LEFT_END │ │ │ │ RIGHT_END │
/// └──────────┘ └────────────────────────────────────┘ └───────────┘ /// └──────────┘ └────────────────────────────────────┘ └───────────┘
/// ┌────────────┐ ┌──────┐ ┌──────────┐ /// ┌────────────┐ ┌──────┐ ┌──────────┐
/// │BOTTOM_START│ │BOTTOM│ │BOTTOM_END│ /// │BOTTOM_START│ │BOTTOM│ │BOTTOM_END│
/// └────────────┘ └──────┘ └──────────┘ /// └────────────┘ └──────┘ └──────────┘
/// ``` /// ```
// There is no `new` function on purpose, since writing out `parent` and `child` is more // There is no `new` function on purpose, since writing out `parent` and `child` is more
// reasonable. // reasonable.
@@ -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 {
@@ -318,127 +345,56 @@ impl std::fmt::Debug for ColorImage {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// A single-channel image designed for the font texture. /// How to convert font coverage values into alpha and color values.
/// //
/// Each value represents "coverage", i.e. how much a texel is covered by a character. // This whole thing is less than rigorous.
/// // Ideally we should do this in a shader instead, and use different computations
/// This is roughly interpreted as the opacity of a white image. // for different text colors.
#[derive(Clone, Default, PartialEq)] // See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct FontImage { pub enum AlphaFromCoverage {
/// width, height /// `alpha = coverage`.
pub size: [usize; 2],
/// The coverage value.
/// ///
/// Often you want to use [`Self::srgba_pixels`] instead. /// Looks good for black-on-white text, i.e. light mode.
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.
/// ///
/// `gamma` should normally be set to `None`. /// Same as [`Self::Gamma`]`(1.0)`, but more efficient.
Linear,
/// `alpha = coverage^gamma`.
Gamma(f32),
/// `alpha = 2 * coverage - coverage^2`
/// ///
/// If you are having problems with text looking skinny and pixelated, try using a low gamma, e.g. `0.4`. /// This looks good for white-on-black text, i.e. dark mode.
#[inline] ///
pub fn srgba_pixels(&self, gamma: Option<f32>) -> impl ExactSizeIterator<Item = Color32> + '_ { /// Very similar to a gamma of 0.5, but produces sharper text.
// This whole function is less than rigorous. /// See <https://www.desmos.com/calculator/w0ndf5blmn> for a comparison to gamma=0.5.
// Ideally we should do this in a shader instead, and use different computations #[default]
// for different text colors. TwoCoverageMinusCoverageSq,
// See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis.
self.pixels.iter().map(move |coverage| {
let alpha = if let Some(gamma) = gamma {
coverage.powf(gamma)
} else {
// alpha = coverage * coverage; // recommended by the article for WHITE text (using linear blending)
// The following is recommended by the article for BLACK text (using linear blending).
// Very similar to a gamma of 0.5, but produces sharper text.
// In practice it works well for all text colors (better than a gamma of 0.5, for instance).
// See https://www.desmos.com/calculator/w0ndf5blmn for a visual comparison.
2.0 * coverage - coverage * coverage
};
Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha))
})
}
/// 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 { impl AlphaFromCoverage {
type Output = f32; /// A good-looking default for light mode (black-on-white text).
pub const LIGHT_MODE_DEFAULT: Self = Self::Linear;
#[inline] /// A good-looking default for dark mode (white-on-black text).
fn index(&self, (x, y): (usize, usize)) -> &f32 { pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq;
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 { /// Convert coverage to alpha.
#[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)] #[inline(always)]
fn from(image: FontImage) -> Self { pub fn alpha_from_coverage(&self, coverage: f32) -> f32 {
Self::Font(image) match self {
Self::Linear => coverage,
Self::Gamma(gamma) => coverage.powf(*gamma),
Self::TwoCoverageMinusCoverageSq => 2.0 * coverage - coverage * coverage,
}
}
#[inline(always)]
pub fn color_from_coverage(&self, coverage: f32) -> Color32 {
let alpha = self.alpha_from_coverage(coverage);
Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha))
} }
} }
@@ -447,7 +403,7 @@ impl From<FontImage> for ImageData {
/// 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::{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

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# This script searches for the last CI run with your branch name, downloads the test_results artefact # This script searches for the last CI run with your branch name, downloads the test_results artifact
# and replaces your existing snapshots with the new ones. # and replaces your existing snapshots with the new ones.
# Make sure you have the gh cli installed and authenticated before running this script. # Make sure you have the gh cli installed and authenticated before running this script.
# If prompted to select a default repo, choose the emilk/egui one # If prompted to select a default repo, choose the emilk/egui one

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ad14068e60fa678ee749925dd3713ee2b12a83ec1bca9c413bdeb9bc27d8ac20 oid sha256:d59882afca42e766dddc36450a3331ca247a130e3796f99d0335ac370a7c3610
size 407795 size 425517

Some files were not shown because too many files have changed in this diff Show More