1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-27 07:03:14 -04:00

Merge branch 'widget_style' of github.com:AdrienZianne/egui into widget_style

This commit is contained in:
adrien
2025-11-10 17:36:03 +01:00
27 changed files with 292 additions and 96 deletions

View File

@@ -239,7 +239,7 @@ impl ComboBox {
let mut ir = combo_box_dyn(
ui,
button_id,
selected_text,
selected_text.clone(),
menu_contents,
icon,
wrap_mode,
@@ -247,14 +247,16 @@ impl ComboBox {
popup_style,
(width, height),
);
ir.response.widget_info(|| {
let mut info = WidgetInfo::new(WidgetType::ComboBox);
info.enabled = ui.is_enabled();
info.current_text_value = Some(selected_text.text().to_owned());
info
});
if let Some(label) = label {
ir.response.widget_info(|| {
WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text())
});
ir.response |= ui.label(label);
} else {
ir.response
.widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), ""));
let label_response = ui.label(label);
ir.response = ir.response.labelled_by(label_response.id);
ir.response |= label_response;
}
ir
})

View File

@@ -3,8 +3,8 @@
use std::ops::{Add, AddAssign, BitOr, BitOrAssign};
use crate::{
Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Sense, Ui, UiBuilder, UiKind,
UiStackInfo, Vec2, Vec2b, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp,
Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder,
UiKind, UiStackInfo, Vec2, Vec2b, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp,
};
#[derive(Clone, Copy, Debug)]
@@ -659,6 +659,9 @@ struct Prepared {
/// not for us to handle so we save it and restore it after this [`ScrollArea`] is done.
saved_scroll_target: [Option<pass_state::ScrollTarget>; 2],
/// The response from dragging the background (if enabled)
background_drag_response: Option<Response>,
animated: bool,
}
@@ -772,70 +775,72 @@ impl ScrollArea {
let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size);
let dt = ui.input(|i| i.stable_dt).at_most(0.1);
if scroll_source.drag
&& ui.is_enabled()
&& (state.content_is_too_large[0] || state.content_is_too_large[1])
{
// Drag contents to scroll (for touch screens mostly).
// We must do this BEFORE adding content to the `ScrollArea`,
// or we will steal input from the widgets we contain.
let content_response_option = state
.interact_rect
.map(|rect| ui.interact(rect, id.with("area"), Sense::drag()));
let background_drag_response =
if scroll_source.drag && ui.is_enabled() && state.content_is_too_large.any() {
// Drag contents to scroll (for touch screens mostly).
// We must do this BEFORE adding content to the `ScrollArea`,
// or we will steal input from the widgets we contain.
let content_response_option = state
.interact_rect
.map(|rect| ui.interact(rect, id.with("area"), Sense::drag()));
if content_response_option
.as_ref()
.is_some_and(|response| response.dragged())
{
for d in 0..2 {
if direction_enabled[d] {
ui.input(|input| {
state.offset[d] -= input.pointer.delta()[d];
});
state.scroll_stuck_to_end[d] = false;
state.offset_target[d] = None;
}
}
} else {
// Apply the cursor velocity to the scroll area when the user releases the drag.
if content_response_option
.as_ref()
.is_some_and(|response| response.drag_stopped())
.is_some_and(|response| response.dragged())
{
state.vel =
direction_enabled.to_vec2() * ui.input(|input| input.pointer.velocity());
}
for d in 0..2 {
// Kinetic scrolling
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.
for d in 0..2 {
if direction_enabled[d] {
ui.input(|input| {
state.offset[d] -= input.pointer.delta()[d];
});
state.scroll_stuck_to_end[d] = false;
state.offset_target[d] = None;
}
}
} else {
// Apply the cursor velocity to the scroll area when the user releases the drag.
if content_response_option
.as_ref()
.is_some_and(|response| response.drag_stopped())
{
state.vel = direction_enabled.to_vec2()
* ui.input(|input| input.pointer.velocity());
}
for d in 0..2 {
// Kinetic scrolling
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.
let friction = friction_coeff * dt;
if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed {
state.vel[d] = 0.0;
} else {
state.vel[d] -= friction * state.vel[d].signum();
// Offset has an inverted coordinate system compared to
// the velocity, so we subtract it instead of adding it
state.offset[d] -= state.vel[d] * dt;
ctx.request_repaint();
let friction = friction_coeff * dt;
if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed {
state.vel[d] = 0.0;
} else {
state.vel[d] -= friction * state.vel[d].signum();
// Offset has an inverted coordinate system compared to
// the velocity, so we subtract it instead of adding it
state.offset[d] -= state.vel[d] * dt;
ctx.request_repaint();
}
}
}
}
// Set the desired mouse cursors.
if let Some(response) = content_response_option {
if response.dragged() {
if let Some(cursor) = on_drag_cursor {
response.on_hover_cursor(cursor);
// Set the desired mouse cursors.
if let Some(response) = &content_response_option {
if response.dragged()
&& let Some(cursor) = on_drag_cursor
{
ui.ctx().set_cursor_icon(cursor);
} else if response.hovered()
&& let Some(cursor) = on_hover_cursor
{
ui.ctx().set_cursor_icon(cursor);
}
} else if response.hovered()
&& let Some(cursor) = on_hover_cursor
{
response.on_hover_cursor(cursor);
}
}
}
content_response_option
} else {
None
};
// Scroll with an animation if we have a target offset (that hasn't been cleared by the code
// above).
@@ -888,6 +893,7 @@ impl ScrollArea {
wheel_scroll_multiplier,
stick_to_end,
saved_scroll_target,
background_drag_response,
animated,
}
}
@@ -1003,6 +1009,7 @@ impl Prepared {
wheel_scroll_multiplier,
stick_to_end,
saved_scroll_target,
background_drag_response,
animated,
} = self;
@@ -1118,7 +1125,16 @@ impl Prepared {
);
let max_offset = content_size - inner_rect.size();
let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect);
// Drag-to-scroll?
let is_dragging_background = background_drag_response
.as_ref()
.is_some_and(|r| r.dragged());
let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect)
&& ui.ctx().dragged_id().is_none()
|| is_dragging_background;
if scroll_source.mouse_wheel && ui.is_enabled() && is_hovering_outer_rect {
let always_scroll_enabled_direction = ui.style().always_scroll_the_only_direction
&& direction_enabled[0] != direction_enabled[1];
@@ -1204,6 +1220,7 @@ impl Prepared {
let is_hovering_bar_area = is_hovering_outer_rect
&& ui.rect_contains_pointer(max_bar_rect)
&& !is_dragging_background
|| state.scroll_bar_interaction[d];
let is_hovering_bar_area_t = ui

View File

@@ -1129,7 +1129,10 @@ impl Visuals {
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct Selection {
/// Background color behind selected text and other selectable buttons.
pub bg_fill: Color32,
/// Color of selected text.
pub stroke: Stroke,
}

View File

@@ -25,7 +25,9 @@ pub fn paint_text_selection(
// and so we need to clone it if it is shared:
let galley: &mut Galley = Arc::make_mut(galley);
let color = visuals.selection.bg_fill;
let background_color = visuals.selection.bg_fill;
let text_color = visuals.selection.stroke.color;
let [min, max] = cursor_range.sorted_cursors();
let min = galley.layout_from_cursor(min);
let max = galley.layout_from_cursor(max);
@@ -53,6 +55,31 @@ pub fn paint_text_selection(
let rect = Rect::from_min_max(pos2(left, 0.0), pos2(right, row.size.y));
let mesh = &mut row.visuals.mesh;
if !row.glyphs.is_empty() {
// Change color of the selected text:
let first_glyph_index = if ri == min.row { min.column } else { 0 };
let last_glyph_index = if ri == max.row {
max.column
} else {
row.glyphs.len() - 1
};
let first_vertex_index = row
.glyphs
.get(first_glyph_index)
.map_or(row.visuals.glyph_vertex_range.start, |g| {
g.first_vertex as _
});
let last_vertex_index = row
.glyphs
.get(last_glyph_index)
.map_or(row.visuals.glyph_vertex_range.end, |g| g.first_vertex as _);
for vi in first_vertex_index..last_vertex_index {
mesh.vertices[vi].color = text_color;
}
}
// Time to insert the selection rectangle into the row mesh.
// It should be on top (after) of any background in the galley,
// but behind (before) any glyphs. The row visuals has this information:
@@ -60,7 +87,7 @@ pub fn paint_text_selection(
// Start by appending the selection rectangle to end of the mesh, as two triangles (= 6 indices):
let num_indices_before = mesh.indices.len();
mesh.add_colored_rect(rect, color);
mesh.add_colored_rect(rect, background_color);
assert_eq!(
num_indices_before + 6,
mesh.indices.len(),

View File

@@ -227,22 +227,29 @@ impl<'a> Button<'a> {
///
/// See also [`Self::right_text`].
#[inline]
pub fn shortcut_text(mut self, shortcut_text: impl Into<Atom<'a>>) -> Self {
let mut atom = shortcut_text.into();
atom.kind = match atom.kind {
AtomKind::Text(text) => AtomKind::Text(text.weak()),
other => other,
};
pub fn shortcut_text(mut self, shortcut_text: impl IntoAtoms<'a>) -> Self {
self.layout.push_right(Atom::grow());
self.layout.push_right(atom);
for mut atom in shortcut_text.into_atoms() {
atom.kind = match atom.kind {
AtomKind::Text(text) => AtomKind::Text(text.weak()),
other => other,
};
self.layout.push_right(atom);
}
self
}
/// Show some text on the right side of the button.
#[inline]
pub fn right_text(mut self, right_text: impl Into<Atom<'a>>) -> Self {
pub fn right_text(mut self, right_text: impl IntoAtoms<'a>) -> Self {
self.layout.push_right(Atom::grow());
self.layout.push_right(right_text.into());
for atom in right_text.into_atoms() {
self.layout.push_right(atom);
}
self
}

View File

@@ -1,4 +1,5 @@
use egui_kittest::Harness;
use egui::{Color32, accesskit::Role};
use egui_kittest::{Harness, kittest::Queryable as _};
#[test]
fn test_kerning() {
@@ -42,7 +43,7 @@ fn test_italics() {
harness.run();
harness.fit_contents();
harness.snapshot(format!(
"image_blending/image_{theme}_x{pixels_per_point:.2}",
"italics/image_{theme}_x{pixels_per_point:.2}",
theme = match theme {
egui::Theme::Dark => "dark",
egui::Theme::Light => "light",
@@ -51,3 +52,24 @@ fn test_italics() {
}
}
}
#[test]
fn test_text_selection() {
let mut harness = Harness::builder().build_ui(|ui| {
let visuals = ui.visuals_mut();
visuals.selection.bg_fill = Color32::LIGHT_GREEN;
visuals.selection.stroke.color = Color32::DARK_BLUE;
ui.label("Some varied ☺ text :)\nAnd it has a second line!");
});
harness.run();
harness.fit_contents();
// Drag to select text:
let label = harness.get_by_role(Role::Label);
harness.drag_at(label.rect().lerp_inside([0.2, 0.25]));
harness.drop_at(label.rect().lerp_inside([0.6, 0.75]));
harness.run();
harness.snapshot("text_selection");
}

View File

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

View File

@@ -8,9 +8,7 @@ mod builder;
mod snapshot;
#[cfg(feature = "snapshot")]
pub use snapshot::*;
use std::fmt::{Debug, Display, Formatter};
use std::time::Duration;
pub use crate::snapshot::*;
mod app_kind;
mod node;
@@ -20,19 +18,26 @@ mod texture_to_image;
#[cfg(feature = "wgpu")]
pub mod wgpu;
pub use kittest;
// re-exports:
pub use {
self::{builder::*, node::*, renderer::*},
kittest,
};
use std::{
fmt::{Debug, Display, Formatter},
time::Duration,
};
use egui::{
Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId,
epaint::{ClippedShape, RectShape},
style::ScrollAnimation,
};
use kittest::Queryable;
use crate::app_kind::AppKind;
pub use builder::*;
pub use node::*;
pub use renderer::*;
use egui::epaint::{ClippedShape, RectShape};
use egui::style::ScrollAnimation;
use egui::{Color32, Key, Modifiers, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId};
use kittest::Queryable;
#[derive(Debug, Clone)]
pub struct ExceededMaxStepsError {
pub max_steps: u64,
@@ -598,6 +603,32 @@ impl<'a, State> Harness<'a, State> {
self.key_combination_modifiers(modifiers, &[key]);
}
/// Move mouse cursor to this position.
pub fn hover_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerMoved(pos));
}
/// Start dragging from a position.
pub fn drag_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerButton {
pos,
button: PointerButton::Primary,
pressed: true,
modifiers: Modifiers::NONE,
});
}
/// Stop dragging and remove cursor.
pub fn drop_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerButton {
pos,
button: PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
});
self.remove_cursor();
}
/// Remove the cursor from the screen.
///
/// Will fire a [`egui::Event::PointerGone`] event.

View File

@@ -449,7 +449,8 @@ impl Rect {
/// Linearly interpolate so that `[0, 0]` is [`Self::min`] and
/// `[1, 1]` is [`Self::max`].
#[inline]
pub fn lerp_inside(&self, t: Vec2) -> Pos2 {
pub fn lerp_inside(&self, t: impl Into<Vec2>) -> Pos2 {
let t = t.into();
Pos2 {
x: lerp(self.min.x..=self.max.x, t.x),
y: lerp(self.min.y..=self.max.y, t.y),

View File

@@ -230,6 +230,7 @@ fn layout_section(
font_ascent: font_metrics.ascent,
uv_rect: glyph_alloc.uv_rect,
section_index,
first_vertex: 0, // filled in later
});
paragraph.cursor_x_px += glyph_alloc.advance_width_px;
@@ -531,6 +532,7 @@ fn replace_last_glyph_with_overflow_character(
font_ascent: font_metrics.ascent,
uv_rect: replacement_glyph_alloc.uv_rect,
section_index,
first_vertex: 0, // filled in later
});
return;
}
@@ -748,7 +750,7 @@ fn tessellate_row(
point_scale: PointScale,
job: &LayoutJob,
format_summary: &FormatSummary,
row: &Row,
row: &mut Row,
) -> RowVisuals {
if row.glyphs.is_empty() {
return Default::default();
@@ -843,8 +845,9 @@ fn add_row_backgrounds(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh
end_run(run_start.take(), last_rect.right());
}
fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) {
for glyph in &row.glyphs {
fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &mut Row, mesh: &mut Mesh) {
for glyph in &mut row.glyphs {
glyph.first_vertex = mesh.vertices.len() as u32;
let uv_rect = glyph.uv_rect;
if !uv_rect.is_nothing() {
let mut left_top = glyph.pos + uv_rect.offset;

View File

@@ -701,6 +701,9 @@ pub struct Glyph {
/// enable the paragraph-concat optimization path without having to
/// adjust `section_index` when concatting.
pub(crate) section_index: u32,
/// Which is our first vertex in [`RowVisuals::mesh`].
pub first_vertex: u32,
}
impl Glyph {

View File

@@ -61,3 +61,17 @@ fn text_edit_rtl() {
harness.snapshot(format!("text_edit_rtl_{i}"));
}
}
#[test]
fn combobox_should_have_value() {
let harness = Harness::new_ui(|ui| {
egui::ComboBox::from_label("Select an option")
.selected_text("Option 1")
.show_ui(ui, |_ui| {});
});
assert_eq!(
harness.get_by_label("Select an option").value().as_deref(),
Some("Option 1")
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,3 +108,14 @@ fn test_intrinsic_size() {
}
}
}
#[test]
fn test_button_shortcut_text() {
let mut harness = HarnessBuilder::default().build_ui(|ui| {
ui.add(egui::Button::new("Click me").shortcut_text(("1", "2", "3")));
});
harness.run();
harness.fit_contents();
harness.snapshot("button_shortcut");
}

View File

@@ -2,7 +2,7 @@ use egui::accesskit::Role;
use egui::load::SizedTexture;
use egui::{
Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, DragValue, Event,
Grid, IntoAtoms as _, Layout, PointerButton, Response, Slider, Stroke, StrokeKind,
Grid, IntoAtoms as _, Layout, PointerButton, Response, Slider, Stroke, StrokeKind, TextEdit,
TextWrapMode, TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, Widget as _, include_image,
};
use egui_kittest::kittest::{Queryable as _, by};
@@ -84,6 +84,37 @@ fn widget_tests() {
},
&mut results,
);
test_widget(
"text_edit_clip",
|ui| {
ui.spacing_mut().text_edit_width = 45.0;
TextEdit::singleline(&mut "This is a very very long text".to_owned())
.clip_text(true)
.ui(ui)
},
&mut results,
);
test_widget(
"text_edit_no_clip",
|ui| {
ui.spacing_mut().text_edit_width = 45.0;
TextEdit::singleline(&mut "This is a very very long text".to_owned())
.clip_text(false)
.ui(ui)
},
&mut results,
);
test_widget(
"text_edit_placeholder_clip",
|ui| {
ui.spacing_mut().text_edit_width = 45.0;
TextEdit::singleline(&mut String::new())
.hint_text("This is a very very long placeholder")
.clip_text(true)
.ui(ui)
},
&mut results,
);
test_widget(
"slider",