mirror of
https://github.com/emilk/egui.git
synced 2026-08-31 22:00:03 -04:00
<!-- Please read the "Making a PR" section of [`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/master/CONTRIBUTING.md) before opening a Pull Request! * Keep your PR:s small and focused. * The PR title is what ends up in the changelog, so make it descriptive! * If applicable, add a screenshot or gif. * If it is a non-trivial addition, consider adding a demo for it to `egui_demo_lib`, or a new example. * Do NOT open PR:s from your `master` branch, as that makes it hard for maintainers to test and add commits to your PR. * Remember to run `cargo fmt` and `cargo clippy`. * Open the PR as a draft until you have self-reviewed it and run `./scripts/check.sh`. * When you have addressed a PR comment, mark it as resolved. Please be patient! I will review your PR, but my time is limited! --> * Closes <https://github.com/emilk/egui/issues/4776> * [x] I have followed the instructions in the PR template I've been meaning to look into this for a while but finally bit the bullet this week. Contrary to what I initially thought, the problem of blurry lines is unrelated to feathering because it also happens with feathering disabled. The root cause is that lines tend to land on pixel boundaries, and because of that, frequently used strokes (e.g. 1pt), end up partially covering pixels. This is especially noticeable on 1ppp displays. There were a couple of things to fix, namely: individual lines like separators and indents but also shape strokes (e.g. Frame). Lines were easy, I just made sure we round them to the nearest pixel _center_, instead of the nearest pixel boundary. Strokes were a little more complicated. To illustrate why, here’s an example: if we're rendering a 5x5 rect (black fill, red stroke), we would expect to see something like this:  The fill and the stroke to cover entire pixels. Instead, egui was painting the stroke partially inside and partially outside, centered around the shape’s path (blue line):  Both methods are valid for different use-cases but the first one is what we’d typically want for UIs to feel crisp and pixel perfect. It's also how CSS borders work (related to #4019 and #3284). Luckily, we can use the normal computed for each `PathPoint` to adjust the location of the stroke to be outside, inside, or in the middle. These also are the 3 types of strokes available in tools like Photoshop. This PR introduces an enum `StrokeKind` which determines if a `PathStroke` should be tessellated outside, inside, or _on_ the path itself. Where "outside" is defined by the directions normals point to. Tessellator will now use `StrokeKind::Outside` for closed shapes like rect, ellipse, etc. And `StrokeKind::Middle` for the rest since there's no meaningful "outside" concept for open paths. This PR doesn't expose `StrokeKind` to user-land, but we can implement that later so that users can render shapes and decide where to place the stroke. ### Strokes test (blue lines represent the size of the rect being rendered) `Stroke::Middle` (current behavior, 1px and 3px are blurry)  `Stroke::Outside` (proposed default behavior for closed paths)  `Stroke::Inside` (for completeness but unused at the moment)  ### Demo App The best way to review this PR is to run the demo on a 1ppp display, especially to test hover effects. Everything should look crisper. Also run it in a higher dpi screen to test that nothing broke 🙏. Before:  After (notice the sharper lines): 
522 lines
16 KiB
Rust
522 lines
16 KiB
Rust
use egui_demo_lib::is_mobile;
|
|
|
|
#[cfg(feature = "glow")]
|
|
use eframe::glow;
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
use core::any::Any;
|
|
|
|
#[derive(Default)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
struct EasyMarkApp {
|
|
editor: egui_demo_lib::easy_mark::EasyMarkEditor,
|
|
}
|
|
|
|
impl eframe::App for EasyMarkApp {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
self.editor.panels(ctx);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
pub struct DemoApp {
|
|
demo_windows: egui_demo_lib::DemoWindows,
|
|
}
|
|
|
|
impl eframe::App for DemoApp {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
self.demo_windows.ui(ctx);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
pub struct FractalClockApp {
|
|
fractal_clock: crate::apps::FractalClock,
|
|
}
|
|
|
|
impl eframe::App for FractalClockApp {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
egui::CentralPanel::default()
|
|
.frame(egui::Frame::dark_canvas(&ctx.style()))
|
|
.show(ctx, |ui| {
|
|
self.fractal_clock
|
|
.ui(ui, Some(crate::seconds_since_midnight()));
|
|
});
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
pub struct ColorTestApp {
|
|
color_test: egui_demo_lib::ColorTest,
|
|
}
|
|
|
|
impl eframe::App for ColorTestApp {
|
|
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
if frame.is_web() {
|
|
ui.label(
|
|
"NOTE: Some old browsers stuck on WebGL1 without sRGB support will not pass the color test.",
|
|
);
|
|
ui.separator();
|
|
}
|
|
egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| {
|
|
self.color_test.ui(ui);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
enum Anchor {
|
|
Demo,
|
|
|
|
EasyMarkEditor,
|
|
|
|
#[cfg(feature = "http")]
|
|
Http,
|
|
|
|
#[cfg(feature = "image_viewer")]
|
|
ImageViewer,
|
|
|
|
Clock,
|
|
|
|
#[cfg(any(feature = "glow", feature = "wgpu"))]
|
|
Custom3d,
|
|
|
|
/// Rendering test
|
|
Rendering,
|
|
}
|
|
|
|
impl Anchor {
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn all() -> Vec<Self> {
|
|
vec![
|
|
Self::Demo,
|
|
Self::EasyMarkEditor,
|
|
#[cfg(feature = "http")]
|
|
Self::Http,
|
|
Self::Clock,
|
|
#[cfg(any(feature = "glow", feature = "wgpu"))]
|
|
Self::Custom3d,
|
|
Self::Rendering,
|
|
]
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Anchor {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{self:?}")
|
|
}
|
|
}
|
|
|
|
impl From<Anchor> for egui::WidgetText {
|
|
fn from(value: Anchor) -> Self {
|
|
Self::RichText(egui::RichText::new(value.to_string()))
|
|
}
|
|
}
|
|
|
|
impl Default for Anchor {
|
|
fn default() -> Self {
|
|
Self::Demo
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
#[must_use]
|
|
enum Command {
|
|
Nothing,
|
|
ResetEverything,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// The state that we persist (serialize).
|
|
#[derive(Default)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
|
#[cfg_attr(feature = "serde", serde(default))]
|
|
pub struct State {
|
|
demo: DemoApp,
|
|
easy_mark_editor: EasyMarkApp,
|
|
#[cfg(feature = "http")]
|
|
http: crate::apps::HttpApp,
|
|
#[cfg(feature = "image_viewer")]
|
|
image_viewer: crate::apps::ImageViewer,
|
|
clock: FractalClockApp,
|
|
rendering_test: ColorTestApp,
|
|
|
|
selected_anchor: Anchor,
|
|
backend_panel: super::backend_panel::BackendPanel,
|
|
}
|
|
|
|
/// Wraps many demo/test apps into one.
|
|
pub struct WrapApp {
|
|
state: State,
|
|
|
|
#[cfg(any(feature = "glow", feature = "wgpu"))]
|
|
custom3d: Option<crate::apps::Custom3d>,
|
|
|
|
dropped_files: Vec<egui::DroppedFile>,
|
|
}
|
|
|
|
impl WrapApp {
|
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
// This gives us image support:
|
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
|
|
|
#[allow(unused_mut)]
|
|
let mut slf = Self {
|
|
state: State::default(),
|
|
|
|
#[cfg(any(feature = "glow", feature = "wgpu"))]
|
|
custom3d: crate::apps::Custom3d::new(cc),
|
|
|
|
dropped_files: Default::default(),
|
|
};
|
|
|
|
#[cfg(feature = "persistence")]
|
|
if let Some(storage) = cc.storage {
|
|
if let Some(state) = eframe::get_value(storage, eframe::APP_KEY) {
|
|
slf.state = state;
|
|
}
|
|
}
|
|
|
|
slf
|
|
}
|
|
|
|
fn apps_iter_mut(&mut self) -> impl Iterator<Item = (&str, Anchor, &mut dyn eframe::App)> {
|
|
let mut vec = vec![
|
|
(
|
|
"✨ Demos",
|
|
Anchor::Demo,
|
|
&mut self.state.demo as &mut dyn eframe::App,
|
|
),
|
|
(
|
|
"🖹 EasyMark editor",
|
|
Anchor::EasyMarkEditor,
|
|
&mut self.state.easy_mark_editor as &mut dyn eframe::App,
|
|
),
|
|
#[cfg(feature = "http")]
|
|
(
|
|
"⬇ HTTP",
|
|
Anchor::Http,
|
|
&mut self.state.http as &mut dyn eframe::App,
|
|
),
|
|
(
|
|
"🕑 Fractal Clock",
|
|
Anchor::Clock,
|
|
&mut self.state.clock as &mut dyn eframe::App,
|
|
),
|
|
#[cfg(feature = "image_viewer")]
|
|
(
|
|
"🖼 Image Viewer",
|
|
Anchor::ImageViewer,
|
|
&mut self.state.image_viewer as &mut dyn eframe::App,
|
|
),
|
|
];
|
|
|
|
#[cfg(any(feature = "glow", feature = "wgpu"))]
|
|
if let Some(custom3d) = &mut self.custom3d {
|
|
vec.push((
|
|
"🔺 3D painting",
|
|
Anchor::Custom3d,
|
|
custom3d as &mut dyn eframe::App,
|
|
));
|
|
}
|
|
|
|
vec.push((
|
|
"🎨 Rendering test",
|
|
Anchor::Rendering,
|
|
&mut self.state.rendering_test as &mut dyn eframe::App,
|
|
));
|
|
|
|
vec.into_iter()
|
|
}
|
|
}
|
|
|
|
impl eframe::App for WrapApp {
|
|
#[cfg(feature = "persistence")]
|
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
|
eframe::set_value(storage, eframe::APP_KEY, &self.state);
|
|
}
|
|
|
|
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
|
|
// Give the area behind the floating windows a different color, because it looks better:
|
|
let color = egui::lerp(
|
|
egui::Rgba::from(visuals.panel_fill)..=egui::Rgba::from(visuals.extreme_bg_color),
|
|
0.5,
|
|
);
|
|
let color = egui::Color32::from(color);
|
|
color.to_normalized_gamma_f32()
|
|
}
|
|
|
|
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
|
#[cfg(target_arch = "wasm32")]
|
|
if let Some(anchor) = frame.info().web_info.location.hash.strip_prefix('#') {
|
|
let anchor = Anchor::all().into_iter().find(|x| x.to_string() == anchor);
|
|
if let Some(v) = anchor {
|
|
self.state.selected_anchor = v;
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F11)) {
|
|
let fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!fullscreen));
|
|
}
|
|
|
|
let mut cmd = Command::Nothing;
|
|
egui::TopBottomPanel::top("wrap_app_top_bar")
|
|
.frame(egui::Frame::none().inner_margin(4.0))
|
|
.show(ctx, |ui| {
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.visuals_mut().button_frame = false;
|
|
self.bar_contents(ui, frame, &mut cmd);
|
|
});
|
|
});
|
|
|
|
self.state.backend_panel.update(ctx, frame);
|
|
|
|
if !is_mobile(ctx) {
|
|
cmd = self.backend_panel(ctx, frame);
|
|
}
|
|
|
|
self.show_selected_app(ctx, frame);
|
|
|
|
self.state.backend_panel.end_of_frame(ctx);
|
|
|
|
self.ui_file_drag_and_drop(ctx);
|
|
|
|
self.run_cmd(ctx, cmd);
|
|
}
|
|
|
|
#[cfg(feature = "glow")]
|
|
fn on_exit(&mut self, gl: Option<&glow::Context>) {
|
|
if let Some(custom3d) = &mut self.custom3d {
|
|
custom3d.on_exit(gl);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
|
|
Some(&mut *self)
|
|
}
|
|
}
|
|
|
|
impl WrapApp {
|
|
fn backend_panel(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) -> Command {
|
|
// The backend-panel can be toggled on/off.
|
|
// We show a little animation when the user switches it.
|
|
let is_open =
|
|
self.state.backend_panel.open || ctx.memory(|mem| mem.everything_is_visible());
|
|
|
|
let mut cmd = Command::Nothing;
|
|
|
|
egui::SidePanel::left("backend_panel")
|
|
.resizable(false)
|
|
.show_animated(ctx, is_open, |ui| {
|
|
ui.add_space(4.0);
|
|
ui.vertical_centered(|ui| {
|
|
ui.heading("💻 Backend");
|
|
});
|
|
|
|
ui.separator();
|
|
self.backend_panel_contents(ui, frame, &mut cmd);
|
|
});
|
|
|
|
cmd
|
|
}
|
|
|
|
fn run_cmd(&mut self, ctx: &egui::Context, cmd: Command) {
|
|
match cmd {
|
|
Command::Nothing => {}
|
|
Command::ResetEverything => {
|
|
self.state = Default::default();
|
|
ctx.memory_mut(|mem| *mem = Default::default());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn backend_panel_contents(
|
|
&mut self,
|
|
ui: &mut egui::Ui,
|
|
frame: &mut eframe::Frame,
|
|
cmd: &mut Command,
|
|
) {
|
|
self.state.backend_panel.ui(ui, frame);
|
|
|
|
ui.separator();
|
|
|
|
ui.horizontal(|ui| {
|
|
if ui
|
|
.button("Reset egui")
|
|
.on_hover_text("Forget scroll, positions, sizes etc")
|
|
.clicked()
|
|
{
|
|
ui.ctx().memory_mut(|mem| *mem = Default::default());
|
|
ui.close_menu();
|
|
}
|
|
|
|
if ui.button("Reset everything").clicked() {
|
|
*cmd = Command::ResetEverything;
|
|
ui.close_menu();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn show_selected_app(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
|
let selected_anchor = self.state.selected_anchor;
|
|
for (_name, anchor, app) in self.apps_iter_mut() {
|
|
if anchor == selected_anchor || ctx.memory(|mem| mem.everything_is_visible()) {
|
|
app.update(ctx, frame);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn bar_contents(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame, cmd: &mut Command) {
|
|
egui::widgets::global_dark_light_mode_switch(ui);
|
|
|
|
ui.separator();
|
|
|
|
if is_mobile(ui.ctx()) {
|
|
ui.menu_button("💻 Backend", |ui| {
|
|
ui.set_style(ui.ctx().style()); // ignore the "menu" style set by `menu_button`.
|
|
self.backend_panel_contents(ui, frame, cmd);
|
|
});
|
|
} else {
|
|
ui.toggle_value(&mut self.state.backend_panel.open, "💻 Backend");
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
let mut selected_anchor = self.state.selected_anchor;
|
|
for (name, anchor, _app) in self.apps_iter_mut() {
|
|
if ui
|
|
.selectable_label(selected_anchor == anchor, name)
|
|
.clicked()
|
|
{
|
|
selected_anchor = anchor;
|
|
if frame.is_web() {
|
|
ui.ctx()
|
|
.open_url(egui::OpenUrl::same_tab(format!("#{anchor}")));
|
|
}
|
|
}
|
|
}
|
|
self.state.selected_anchor = selected_anchor;
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if false {
|
|
// TODO(emilk): fix the overlap on small screens
|
|
if clock_button(ui, crate::seconds_since_midnight()).clicked() {
|
|
self.state.selected_anchor = Anchor::Clock;
|
|
if frame.is_web() {
|
|
ui.ctx().open_url(egui::OpenUrl::same_tab("#clock"));
|
|
}
|
|
}
|
|
}
|
|
|
|
egui::warn_if_debug_build(ui);
|
|
});
|
|
}
|
|
|
|
fn ui_file_drag_and_drop(&mut self, ctx: &egui::Context) {
|
|
use egui::{Align2, Color32, Id, LayerId, Order, TextStyle};
|
|
use std::fmt::Write as _;
|
|
|
|
// Preview hovering files:
|
|
if !ctx.input(|i| i.raw.hovered_files.is_empty()) {
|
|
let text = ctx.input(|i| {
|
|
let mut text = "Dropping files:\n".to_owned();
|
|
for file in &i.raw.hovered_files {
|
|
if let Some(path) = &file.path {
|
|
write!(text, "\n{}", path.display()).ok();
|
|
} else if !file.mime.is_empty() {
|
|
write!(text, "\n{}", file.mime).ok();
|
|
} else {
|
|
text += "\n???";
|
|
}
|
|
}
|
|
text
|
|
});
|
|
|
|
let painter =
|
|
ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("file_drop_target")));
|
|
|
|
let screen_rect = ctx.screen_rect();
|
|
painter.rect_filled(screen_rect, 0.0, Color32::from_black_alpha(192));
|
|
painter.text(
|
|
screen_rect.center(),
|
|
Align2::CENTER_CENTER,
|
|
text,
|
|
TextStyle::Heading.resolve(&ctx.style()),
|
|
Color32::WHITE,
|
|
);
|
|
}
|
|
|
|
// Collect dropped files:
|
|
ctx.input(|i| {
|
|
if !i.raw.dropped_files.is_empty() {
|
|
self.dropped_files.clone_from(&i.raw.dropped_files);
|
|
}
|
|
});
|
|
|
|
// Show dropped files (if any):
|
|
if !self.dropped_files.is_empty() {
|
|
let mut open = true;
|
|
egui::Window::new("Dropped files")
|
|
.open(&mut open)
|
|
.show(ctx, |ui| {
|
|
for file in &self.dropped_files {
|
|
let mut info = if let Some(path) = &file.path {
|
|
path.display().to_string()
|
|
} else if !file.name.is_empty() {
|
|
file.name.clone()
|
|
} else {
|
|
"???".to_owned()
|
|
};
|
|
|
|
let mut additional_info = vec![];
|
|
if !file.mime.is_empty() {
|
|
additional_info.push(format!("type: {}", file.mime));
|
|
}
|
|
if let Some(bytes) = &file.bytes {
|
|
additional_info.push(format!("{} bytes", bytes.len()));
|
|
}
|
|
if !additional_info.is_empty() {
|
|
info += &format!(" ({})", additional_info.join(", "));
|
|
}
|
|
|
|
ui.label(info);
|
|
}
|
|
});
|
|
if !open {
|
|
self.dropped_files.clear();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn clock_button(ui: &mut egui::Ui, seconds_since_midnight: f64) -> egui::Response {
|
|
let time = seconds_since_midnight;
|
|
let time = format!(
|
|
"{:02}:{:02}:{:02}.{:02}",
|
|
(time % (24.0 * 60.0 * 60.0) / 3600.0).floor(),
|
|
(time % (60.0 * 60.0) / 60.0).floor(),
|
|
(time % 60.0).floor(),
|
|
(time % 1.0 * 100.0).floor()
|
|
);
|
|
|
|
ui.button(egui::RichText::new(time).monospace())
|
|
}
|