Add `egui::Scene` for panning/zooming a `Ui` (#5505)
This is similar to `ScrollArea`, but: * Supports zooming * Has no scroll bars * Has no limits on the scrolling ## TODO * [x] Automatic sizing of `Scene`s outer bounds * [x] Fix text selection in scenes * [x] Implement `fit_rect` * [x] Document / improve API --------- Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
parent
37c564be2c
commit
e8f351b729
|
|
@ -10,6 +10,7 @@ pub mod modal;
|
|||
pub mod panel;
|
||||
pub mod popup;
|
||||
pub(crate) mod resize;
|
||||
mod scene;
|
||||
pub mod scroll_area;
|
||||
mod sides;
|
||||
pub(crate) mod window;
|
||||
|
|
@ -23,6 +24,7 @@ pub use {
|
|||
panel::{CentralPanel, SidePanel, TopBottomPanel},
|
||||
popup::*,
|
||||
resize::Resize,
|
||||
scene::Scene,
|
||||
scroll_area::ScrollArea,
|
||||
sides::Sides,
|
||||
window::Window,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
use core::f32;
|
||||
|
||||
use emath::{GuiRounding, Pos2};
|
||||
|
||||
use crate::{
|
||||
emath::TSTransform, InnerResponse, LayerId, Rangef, Rect, Response, Sense, Ui, UiBuilder, Vec2,
|
||||
};
|
||||
|
||||
/// Creates a transformation that fits a given scene rectangle into the available screen size.
|
||||
///
|
||||
/// The resulting visual scene bounds can be larger, due to letterboxing.
|
||||
///
|
||||
/// Returns the transformation from `scene` to `global` coordinates.
|
||||
fn fit_to_rect_in_scene(
|
||||
rect_in_global: Rect,
|
||||
rect_in_scene: Rect,
|
||||
zoom_range: Rangef,
|
||||
) -> TSTransform {
|
||||
// Compute the scale factor to fit the bounding rectangle into the available screen size:
|
||||
let scale = rect_in_global.size() / rect_in_scene.size();
|
||||
|
||||
// Use the smaller of the two scales to ensure the whole rectangle fits on the screen:
|
||||
let scale = scale.min_elem();
|
||||
|
||||
// Clamp scale to what is allowed
|
||||
let scale = zoom_range.clamp(scale);
|
||||
|
||||
// Compute the translation to center the bounding rect in the screen:
|
||||
let center_in_global = rect_in_global.center().to_vec2();
|
||||
let center_scene = rect_in_scene.center().to_vec2();
|
||||
|
||||
// Set the transformation to scale and then translate to center.
|
||||
TSTransform::from_translation(center_in_global - scale * center_scene)
|
||||
* TSTransform::from_scaling(scale)
|
||||
}
|
||||
|
||||
/// A container that allows you to zoom and pan.
|
||||
///
|
||||
/// This is similar to [`crate::ScrollArea`] but:
|
||||
/// * Supports zooming
|
||||
/// * Has no scroll bars
|
||||
/// * Has no limits on the scrolling
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use = "You should call .show()"]
|
||||
pub struct Scene {
|
||||
zoom_range: Rangef,
|
||||
max_inner_size: Vec2,
|
||||
}
|
||||
|
||||
impl Default for Scene {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zoom_range: Rangef::new(f32::EPSILON, 1.0),
|
||||
max_inner_size: Vec2::splat(1000.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Set the allowed zoom range.
|
||||
///
|
||||
/// The default zoom range is `0.0..=1.0`,
|
||||
/// which mean you zan make things arbitrarily small, but you cannot zoom in past a `1:1` ratio.
|
||||
///
|
||||
/// If you want to allow zooming in, you can set the zoom range to `0.0..=f32::INFINITY`.
|
||||
/// Note that text rendering becomes blurry when you zoom in: <https://github.com/emilk/egui/issues/4813>.
|
||||
#[inline]
|
||||
pub fn zoom_range(mut self, zoom_range: impl Into<Rangef>) -> Self {
|
||||
self.zoom_range = zoom_range.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the maximum size of the inner [`Ui`] that will be created.
|
||||
#[inline]
|
||||
pub fn max_inner_size(mut self, max_inner_size: impl Into<Vec2>) -> Self {
|
||||
self.max_inner_size = max_inner_size.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// `scene_rect` contains the view bounds of the inner [`Ui`].
|
||||
///
|
||||
/// `scene_rect` will be mutated by any panning/zooming done by the user.
|
||||
/// If `scene_rect` is somehow invalid (e.g. `Rect::ZERO`),
|
||||
/// then it will be reset to the inner rect of the inner ui.
|
||||
///
|
||||
/// You need to store the `scene_rect` in your state between frames.
|
||||
pub fn show<R>(
|
||||
&self,
|
||||
parent_ui: &mut Ui,
|
||||
scene_rect: &mut Rect,
|
||||
add_contents: impl FnOnce(&mut Ui) -> R,
|
||||
) -> InnerResponse<R> {
|
||||
let (outer_rect, _outer_response) =
|
||||
parent_ui.allocate_exact_size(parent_ui.available_size_before_wrap(), Sense::hover());
|
||||
|
||||
let mut to_global = fit_to_rect_in_scene(outer_rect, *scene_rect, self.zoom_range);
|
||||
|
||||
let scene_rect_was_good =
|
||||
to_global.is_valid() && scene_rect.is_finite() && scene_rect.size() != Vec2::ZERO;
|
||||
|
||||
let mut inner_rect = *scene_rect;
|
||||
|
||||
let ret = self.show_global_transform(parent_ui, outer_rect, &mut to_global, |ui| {
|
||||
let r = add_contents(ui);
|
||||
inner_rect = ui.min_rect();
|
||||
r
|
||||
});
|
||||
|
||||
if ret.response.changed() {
|
||||
// Only update if changed, both to avoid numeric drift,
|
||||
// and to avoid expanding the scene rect unnecessarily.
|
||||
*scene_rect = to_global.inverse() * outer_rect;
|
||||
}
|
||||
|
||||
if !scene_rect_was_good {
|
||||
// Auto-reset if the trsnsformation goes bad somehow (or started bad).
|
||||
*scene_rect = inner_rect;
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
fn show_global_transform<R>(
|
||||
&self,
|
||||
parent_ui: &mut Ui,
|
||||
outer_rect: Rect,
|
||||
to_global: &mut TSTransform,
|
||||
add_contents: impl FnOnce(&mut Ui) -> R,
|
||||
) -> InnerResponse<R> {
|
||||
// Create a new egui paint layer, where we can draw our contents:
|
||||
let scene_layer_id = LayerId::new(
|
||||
parent_ui.layer_id().order,
|
||||
parent_ui.id().with("scene_area"),
|
||||
);
|
||||
|
||||
// Put the layer directly on-top of the main layer of the ui:
|
||||
parent_ui
|
||||
.ctx()
|
||||
.set_sublayer(parent_ui.layer_id(), scene_layer_id);
|
||||
|
||||
let mut local_ui = parent_ui.new_child(
|
||||
UiBuilder::new()
|
||||
.layer_id(scene_layer_id)
|
||||
.max_rect(Rect::from_min_size(Pos2::ZERO, self.max_inner_size))
|
||||
.sense(Sense::click_and_drag()),
|
||||
);
|
||||
|
||||
let mut pan_response = local_ui.response();
|
||||
|
||||
// Update the `to_global` transform based on use interaction:
|
||||
self.register_pan_and_zoom(&local_ui, &mut pan_response, to_global);
|
||||
|
||||
// Set a correct global clip rect:
|
||||
local_ui.set_clip_rect(to_global.inverse() * outer_rect);
|
||||
|
||||
// Add the actual contents to the area:
|
||||
let ret = add_contents(&mut local_ui);
|
||||
|
||||
// This ensures we catch clicks/drags/pans anywhere on the background.
|
||||
local_ui.force_set_min_rect((to_global.inverse() * outer_rect).round_ui());
|
||||
|
||||
// Tell egui to apply the transform on the layer:
|
||||
local_ui
|
||||
.ctx()
|
||||
.set_transform_layer(scene_layer_id, *to_global);
|
||||
|
||||
InnerResponse {
|
||||
response: pan_response,
|
||||
inner: ret,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to handle pan and zoom interactions on a response.
|
||||
pub fn register_pan_and_zoom(&self, ui: &Ui, resp: &mut Response, to_global: &mut TSTransform) {
|
||||
if resp.dragged() {
|
||||
to_global.translation += to_global.scaling * resp.drag_delta();
|
||||
resp.mark_changed();
|
||||
}
|
||||
|
||||
if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) {
|
||||
if resp.contains_pointer() {
|
||||
let pointer_in_scene = to_global.inverse() * mouse_pos;
|
||||
let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
|
||||
let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);
|
||||
|
||||
// Most of the time we can return early. This is also important to
|
||||
// avoid `ui_from_scene` to change slightly due to floating point errors.
|
||||
if zoom_delta == 1.0 && pan_delta == Vec2::ZERO {
|
||||
return;
|
||||
}
|
||||
|
||||
if zoom_delta != 1.0 {
|
||||
// Zoom in on pointer, but only if we are not zoomed in or out too far.
|
||||
let zoom_delta = zoom_delta.clamp(
|
||||
self.zoom_range.min / to_global.scaling,
|
||||
self.zoom_range.max / to_global.scaling,
|
||||
);
|
||||
|
||||
*to_global = *to_global
|
||||
* TSTransform::from_translation(pointer_in_scene.to_vec2())
|
||||
* TSTransform::from_scaling(zoom_delta)
|
||||
* TSTransform::from_translation(-pointer_in_scene.to_vec2());
|
||||
|
||||
// Clamp to exact zoom range.
|
||||
to_global.scaling = self.zoom_range.clamp(to_global.scaling);
|
||||
}
|
||||
|
||||
// Pan:
|
||||
*to_global = TSTransform::from_translation(pan_delta) * *to_global;
|
||||
resp.mark_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -161,6 +161,9 @@ impl ScrollBarVisibility {
|
|||
/// ```
|
||||
///
|
||||
/// You can scroll to an element using [`crate::Response::scroll_to_me`], [`Ui::scroll_to_cursor`] and [`Ui::scroll_to_rect`].
|
||||
///
|
||||
/// ## See also
|
||||
/// If you want to allow zooming, use [`crate::Scene`].
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use = "You should call .show()"]
|
||||
pub struct ScrollArea {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use crate::{Context, Galley, Id, Pos2};
|
||||
use emath::TSTransform;
|
||||
|
||||
use crate::{Context, Galley, Id};
|
||||
|
||||
use super::{text_cursor_state::is_word_char, CursorRange};
|
||||
|
||||
|
|
@ -8,7 +10,7 @@ pub fn update_accesskit_for_text_widget(
|
|||
widget_id: Id,
|
||||
cursor_range: Option<CursorRange>,
|
||||
role: accesskit::Role,
|
||||
galley_pos: Pos2,
|
||||
global_from_galley: TSTransform,
|
||||
galley: &Galley,
|
||||
) {
|
||||
let parent_id = ctx.accesskit_node_builder(widget_id, |builder| {
|
||||
|
|
@ -43,7 +45,7 @@ pub fn update_accesskit_for_text_widget(
|
|||
let row_id = parent_id.with(row_index);
|
||||
ctx.accesskit_node_builder(row_id, |builder| {
|
||||
builder.set_role(accesskit::Role::TextRun);
|
||||
let rect = row.rect.translate(galley_pos.to_vec2());
|
||||
let rect = global_from_galley * row.rect;
|
||||
builder.set_bounds(accesskit::Rect {
|
||||
x0: rect.min.x.into(),
|
||||
y0: rect.min.y.into(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use emath::TSTransform;
|
||||
|
||||
use crate::{
|
||||
layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event,
|
||||
Galley, Id, LayerId, Pos2, Rect, Response, Ui,
|
||||
|
|
@ -25,9 +27,14 @@ struct WidgetTextCursor {
|
|||
}
|
||||
|
||||
impl WidgetTextCursor {
|
||||
fn new(widget_id: Id, cursor: impl Into<CCursor>, galley_pos: Pos2, galley: &Galley) -> Self {
|
||||
fn new(
|
||||
widget_id: Id,
|
||||
cursor: impl Into<CCursor>,
|
||||
global_from_galley: TSTransform,
|
||||
galley: &Galley,
|
||||
) -> Self {
|
||||
let ccursor = cursor.into();
|
||||
let pos = pos_in_galley(galley_pos, galley, ccursor);
|
||||
let pos = global_from_galley * pos_in_galley(galley, ccursor);
|
||||
Self {
|
||||
widget_id,
|
||||
ccursor,
|
||||
|
|
@ -36,8 +43,8 @@ impl WidgetTextCursor {
|
|||
}
|
||||
}
|
||||
|
||||
fn pos_in_galley(galley_pos: Pos2, galley: &Galley, ccursor: CCursor) -> Pos2 {
|
||||
galley_pos + galley.pos_from_ccursor(ccursor).center().to_vec2()
|
||||
fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 {
|
||||
galley.pos_from_ccursor(ccursor).center()
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WidgetTextCursor {
|
||||
|
|
@ -228,8 +235,7 @@ impl LabelSelectionState {
|
|||
self.selection = None;
|
||||
}
|
||||
|
||||
fn copy_text(&mut self, galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange) {
|
||||
let new_galley_rect = Rect::from_min_size(galley_pos, galley.size());
|
||||
fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CursorRange) {
|
||||
let new_text = selected_text(galley, cursor_range);
|
||||
if new_text.is_empty() {
|
||||
return;
|
||||
|
|
@ -308,7 +314,7 @@ impl LabelSelectionState {
|
|||
&mut self,
|
||||
ui: &Ui,
|
||||
response: &Response,
|
||||
galley_pos: Pos2,
|
||||
global_from_galley: TSTransform,
|
||||
galley: &Galley,
|
||||
) -> TextCursorState {
|
||||
let Some(selection) = &mut self.selection else {
|
||||
|
|
@ -321,6 +327,8 @@ impl LabelSelectionState {
|
|||
return TextCursorState::default();
|
||||
}
|
||||
|
||||
let galley_from_global = global_from_galley.inverse();
|
||||
|
||||
let multi_widget_text_select = ui.style().interaction.multi_widget_text_select;
|
||||
|
||||
let may_select_widget =
|
||||
|
|
@ -328,7 +336,8 @@ impl LabelSelectionState {
|
|||
|
||||
if self.is_dragging && may_select_widget {
|
||||
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
||||
let galley_rect = Rect::from_min_size(galley_pos, galley.size());
|
||||
let galley_rect =
|
||||
global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
|
||||
let galley_rect = galley_rect.intersect(ui.clip_rect());
|
||||
|
||||
let is_in_same_column = galley_rect
|
||||
|
|
@ -342,7 +351,7 @@ impl LabelSelectionState {
|
|||
|
||||
let new_primary = if response.contains_pointer() {
|
||||
// Dragging into this widget - easy case:
|
||||
Some(galley.cursor_from_pos(pointer_pos - galley_pos))
|
||||
Some(galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2()))
|
||||
} else if is_in_same_column
|
||||
&& !self.has_reached_primary
|
||||
&& selection.primary.pos.y <= selection.secondary.pos.y
|
||||
|
|
@ -376,7 +385,7 @@ impl LabelSelectionState {
|
|||
|
||||
if let Some(new_primary) = new_primary {
|
||||
selection.primary =
|
||||
WidgetTextCursor::new(response.id, new_primary, galley_pos, galley);
|
||||
WidgetTextCursor::new(response.id, new_primary, global_from_galley, galley);
|
||||
|
||||
// We don't want the latency of `drag_started`.
|
||||
let drag_started = ui.input(|i| i.pointer.any_pressed());
|
||||
|
|
@ -402,11 +411,12 @@ impl LabelSelectionState {
|
|||
let has_secondary = response.id == selection.secondary.widget_id;
|
||||
|
||||
if has_primary {
|
||||
selection.primary.pos = pos_in_galley(galley_pos, galley, selection.primary.ccursor);
|
||||
selection.primary.pos =
|
||||
global_from_galley * pos_in_galley(galley, selection.primary.ccursor);
|
||||
}
|
||||
if has_secondary {
|
||||
selection.secondary.pos =
|
||||
pos_in_galley(galley_pos, galley, selection.secondary.ccursor);
|
||||
global_from_galley * pos_in_galley(galley, selection.secondary.ccursor);
|
||||
}
|
||||
|
||||
self.has_reached_primary |= has_primary;
|
||||
|
|
@ -479,11 +489,21 @@ impl LabelSelectionState {
|
|||
&mut self,
|
||||
ui: &Ui,
|
||||
response: &Response,
|
||||
galley_pos: Pos2,
|
||||
galley_pos_in_layer: Pos2,
|
||||
galley: &mut Arc<Galley>,
|
||||
) -> Vec<RowVertexIndices> {
|
||||
let widget_id = response.id;
|
||||
|
||||
let global_from_layer = ui
|
||||
.ctx()
|
||||
.layer_transform_to_global(ui.layer_id())
|
||||
.unwrap_or_default();
|
||||
let layer_from_galley = TSTransform::from_translation(galley_pos_in_layer.to_vec2());
|
||||
let galley_from_layer = layer_from_galley.inverse();
|
||||
let layer_from_global = global_from_layer.inverse();
|
||||
let galley_from_global = galley_from_layer * layer_from_global;
|
||||
let global_from_galley = global_from_layer * layer_from_galley;
|
||||
|
||||
if response.hovered() {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::Text);
|
||||
}
|
||||
|
|
@ -493,13 +513,14 @@ impl LabelSelectionState {
|
|||
|
||||
let old_selection = self.selection;
|
||||
|
||||
let mut cursor_state = self.cursor_for(ui, response, galley_pos, galley);
|
||||
let mut cursor_state = self.cursor_for(ui, response, global_from_galley, galley);
|
||||
|
||||
let old_range = cursor_state.range(galley);
|
||||
|
||||
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
||||
if response.contains_pointer() {
|
||||
let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos);
|
||||
let cursor_at_pointer =
|
||||
galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2());
|
||||
|
||||
// This is where we handle start-of-drag and double-click-to-select.
|
||||
// Actual drag-to-select happens elsewhere.
|
||||
|
|
@ -509,7 +530,7 @@ impl LabelSelectionState {
|
|||
}
|
||||
|
||||
if let Some(mut cursor_range) = cursor_state.range(galley) {
|
||||
let galley_rect = Rect::from_min_size(galley_pos, galley.size());
|
||||
let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
|
||||
self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect);
|
||||
|
||||
if let Some(selection) = &self.selection {
|
||||
|
|
@ -519,7 +540,7 @@ impl LabelSelectionState {
|
|||
}
|
||||
|
||||
if got_copy_event(ui.ctx()) {
|
||||
self.copy_text(galley_pos, galley, &cursor_range);
|
||||
self.copy_text(galley_rect, galley, &cursor_range);
|
||||
}
|
||||
|
||||
cursor_state.set_range(Some(cursor_range));
|
||||
|
|
@ -541,23 +562,32 @@ impl LabelSelectionState {
|
|||
|
||||
if primary_changed || !ui.style().interaction.multi_widget_text_select {
|
||||
selection.primary =
|
||||
WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley);
|
||||
WidgetTextCursor::new(widget_id, range.primary, global_from_galley, galley);
|
||||
self.has_reached_primary = true;
|
||||
}
|
||||
if secondary_changed || !ui.style().interaction.multi_widget_text_select {
|
||||
selection.secondary =
|
||||
WidgetTextCursor::new(widget_id, range.secondary, galley_pos, galley);
|
||||
selection.secondary = WidgetTextCursor::new(
|
||||
widget_id,
|
||||
range.secondary,
|
||||
global_from_galley,
|
||||
galley,
|
||||
);
|
||||
self.has_reached_secondary = true;
|
||||
}
|
||||
} else {
|
||||
// Start of a new selection
|
||||
self.selection = Some(CurrentSelection {
|
||||
layer_id: response.layer_id,
|
||||
primary: WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley),
|
||||
primary: WidgetTextCursor::new(
|
||||
widget_id,
|
||||
range.primary,
|
||||
global_from_galley,
|
||||
galley,
|
||||
),
|
||||
secondary: WidgetTextCursor::new(
|
||||
widget_id,
|
||||
range.secondary,
|
||||
galley_pos,
|
||||
global_from_galley,
|
||||
galley,
|
||||
),
|
||||
});
|
||||
|
|
@ -580,7 +610,7 @@ impl LabelSelectionState {
|
|||
// Scroll to keep primary cursor in view:
|
||||
let row_height = estimate_row_height(galley);
|
||||
let primary_cursor_rect =
|
||||
cursor_rect(galley_pos, galley, &range.primary, row_height);
|
||||
global_from_galley * cursor_rect(galley, &range.primary, row_height);
|
||||
ui.scroll_to_rect(primary_cursor_rect, None);
|
||||
}
|
||||
}
|
||||
|
|
@ -606,7 +636,7 @@ impl LabelSelectionState {
|
|||
response.id,
|
||||
cursor_range,
|
||||
accesskit::Role::Label,
|
||||
galley_pos,
|
||||
global_from_galley,
|
||||
galley,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use epaint::text::{
|
|||
Galley,
|
||||
};
|
||||
|
||||
use crate::{epaint, NumExt, Pos2, Rect, Response, Ui};
|
||||
use crate::{epaint, NumExt, Rect, Response, Ui};
|
||||
|
||||
use super::{CCursorRange, CursorRange};
|
||||
|
||||
|
|
@ -335,14 +335,14 @@ pub fn slice_char_range(s: &str, char_range: std::ops::Range<usize>) -> &str {
|
|||
&s[start_byte..end_byte]
|
||||
}
|
||||
|
||||
/// The thin rectangle of one end of the selection, e.g. the primary cursor.
|
||||
pub fn cursor_rect(galley_pos: Pos2, galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect {
|
||||
let mut cursor_pos = galley
|
||||
.pos_from_cursor(cursor)
|
||||
.translate(galley_pos.to_vec2());
|
||||
cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
|
||||
/// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates.
|
||||
pub fn cursor_rect(galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect {
|
||||
let mut cursor_pos = galley.pos_from_cursor(cursor);
|
||||
|
||||
// Handle completely empty galleys
|
||||
cursor_pos = cursor_pos.expand(1.5);
|
||||
// slightly above/below row
|
||||
cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
|
||||
|
||||
cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
|
||||
|
||||
cursor_pos
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use emath::Rect;
|
||||
use emath::{Rect, TSTransform};
|
||||
use epaint::text::{cursor::CCursor, Galley, LayoutJob};
|
||||
|
||||
use crate::{
|
||||
|
|
@ -587,8 +587,8 @@ impl TextEdit<'_> {
|
|||
&& ui.input(|i| i.pointer.is_moving())
|
||||
{
|
||||
// text cursor preview:
|
||||
let cursor_rect =
|
||||
cursor_rect(rect.min, &galley, &cursor_at_pointer, row_height);
|
||||
let cursor_rect = TSTransform::from_translation(rect.min.to_vec2())
|
||||
* cursor_rect(&galley, &cursor_at_pointer, row_height);
|
||||
text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect);
|
||||
}
|
||||
|
||||
|
|
@ -738,7 +738,8 @@ impl TextEdit<'_> {
|
|||
if has_focus {
|
||||
if let Some(cursor_range) = state.cursor.range(&galley) {
|
||||
let primary_cursor_rect =
|
||||
cursor_rect(galley_pos, &galley, &cursor_range.primary, row_height);
|
||||
cursor_rect(&galley, &cursor_range.primary, row_height)
|
||||
.translate(galley_pos.to_vec2());
|
||||
|
||||
if response.changed() || selection_changed {
|
||||
// Scroll to keep primary cursor in view:
|
||||
|
|
@ -837,7 +838,7 @@ impl TextEdit<'_> {
|
|||
id,
|
||||
cursor_range,
|
||||
role,
|
||||
galley_pos,
|
||||
TSTransform::from_translation(galley_pos.to_vec2()),
|
||||
&galley,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ impl Default for DemoGroups {
|
|||
Box::<super::modals::Modals>::default(),
|
||||
Box::<super::multi_touch::MultiTouch>::default(),
|
||||
Box::<super::painting::Painting>::default(),
|
||||
Box::<super::pan_zoom::PanZoom>::default(),
|
||||
Box::<super::panels::Panels>::default(),
|
||||
Box::<super::scene::SceneDemo>::default(),
|
||||
Box::<super::screenshot::Screenshot>::default(),
|
||||
Box::<super::scrolling::Scrolling>::default(),
|
||||
Box::<super::sliders::Sliders>::default(),
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ pub mod modals;
|
|||
pub mod multi_touch;
|
||||
pub mod paint_bezier;
|
||||
pub mod painting;
|
||||
pub mod pan_zoom;
|
||||
pub mod panels;
|
||||
pub mod password;
|
||||
pub mod scene;
|
||||
pub mod screenshot;
|
||||
pub mod scrolling;
|
||||
pub mod sliders;
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
use egui::emath::TSTransform;
|
||||
use egui::TextWrapMode;
|
||||
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct PanZoom {
|
||||
transform: TSTransform,
|
||||
drag_value: f32,
|
||||
}
|
||||
|
||||
impl Eq for PanZoom {}
|
||||
|
||||
impl crate::Demo for PanZoom {
|
||||
fn name(&self) -> &'static str {
|
||||
"🔍 Pan Zoom"
|
||||
}
|
||||
|
||||
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
|
||||
use crate::View as _;
|
||||
let window = egui::Window::new("Pan Zoom")
|
||||
.default_width(300.0)
|
||||
.default_height(300.0)
|
||||
.vscroll(false)
|
||||
.open(open);
|
||||
window.show(ctx, |ui| self.ui(ui));
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::View for PanZoom {
|
||||
fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
ui.label(
|
||||
"Pan, zoom in, and zoom out with scrolling (see the plot demo for more instructions). \
|
||||
Double click on the background to reset.",
|
||||
);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add(crate::egui_github_link_file!());
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let (id, rect) = ui.allocate_space(ui.available_size());
|
||||
let response = ui.interact(rect, id, egui::Sense::click_and_drag());
|
||||
// Allow dragging the background as well.
|
||||
if response.dragged() {
|
||||
self.transform.translation += response.drag_delta();
|
||||
}
|
||||
|
||||
// Plot-like reset
|
||||
if response.double_clicked() {
|
||||
self.transform = TSTransform::default();
|
||||
}
|
||||
|
||||
let transform =
|
||||
TSTransform::from_translation(ui.min_rect().left_top().to_vec2()) * self.transform;
|
||||
|
||||
if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) {
|
||||
// Note: doesn't catch zooming / panning if a button in this PanZoom container is hovered.
|
||||
if response.hovered() {
|
||||
let pointer_in_layer = transform.inverse() * pointer;
|
||||
let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
|
||||
let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);
|
||||
|
||||
// Zoom in on pointer:
|
||||
self.transform = self.transform
|
||||
* TSTransform::from_translation(pointer_in_layer.to_vec2())
|
||||
* TSTransform::from_scaling(zoom_delta)
|
||||
* TSTransform::from_translation(-pointer_in_layer.to_vec2());
|
||||
|
||||
// Pan:
|
||||
self.transform = TSTransform::from_translation(pan_delta) * self.transform;
|
||||
}
|
||||
}
|
||||
|
||||
for (i, (pos, callback)) in [
|
||||
(
|
||||
egui::Pos2::new(0.0, 0.0),
|
||||
Box::new(|ui: &mut egui::Ui, _: &mut Self| {
|
||||
ui.button("top left").on_hover_text("Normal tooltip")
|
||||
}) as Box<dyn Fn(&mut egui::Ui, &mut Self) -> egui::Response>,
|
||||
),
|
||||
(
|
||||
egui::Pos2::new(0.0, 120.0),
|
||||
Box::new(|ui: &mut egui::Ui, _| {
|
||||
ui.button("bottom left").on_hover_text("Normal tooltip")
|
||||
}),
|
||||
),
|
||||
(
|
||||
egui::Pos2::new(120.0, 120.0),
|
||||
Box::new(|ui: &mut egui::Ui, _| {
|
||||
ui.button("right bottom")
|
||||
.on_hover_text_at_pointer("Tooltip at pointer")
|
||||
}),
|
||||
),
|
||||
(
|
||||
egui::Pos2::new(120.0, 0.0),
|
||||
Box::new(|ui: &mut egui::Ui, _| {
|
||||
ui.button("right top")
|
||||
.on_hover_text_at_pointer("Tooltip at pointer")
|
||||
}),
|
||||
),
|
||||
(
|
||||
egui::Pos2::new(60.0, 60.0),
|
||||
Box::new(|ui, state| {
|
||||
use egui::epaint::{pos2, CircleShape, Color32, QuadraticBezierShape, Stroke};
|
||||
// Smiley face.
|
||||
let painter = ui.painter();
|
||||
painter.add(CircleShape::filled(pos2(0.0, -10.0), 1.0, Color32::YELLOW));
|
||||
painter.add(CircleShape::filled(pos2(10.0, -10.0), 1.0, Color32::YELLOW));
|
||||
painter.add(QuadraticBezierShape::from_points_stroke(
|
||||
[pos2(0.0, 0.0), pos2(5.0, 3.0), pos2(10.0, 0.0)],
|
||||
false,
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.0, Color32::YELLOW),
|
||||
));
|
||||
|
||||
ui.add(egui::Slider::new(&mut state.drag_value, 0.0..=100.0).text("My value"))
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let window_layer = ui.layer_id();
|
||||
let id = egui::Area::new(id.with(("subarea", i)))
|
||||
.default_pos(pos)
|
||||
.order(egui::Order::Middle)
|
||||
.constrain(false)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_clip_rect(transform.inverse() * rect);
|
||||
egui::Frame::default()
|
||||
.rounding(egui::Rounding::same(4))
|
||||
.inner_margin(egui::Margin::same(8))
|
||||
.stroke(ui.ctx().style().visuals.window_stroke)
|
||||
.fill(ui.style().visuals.panel_fill)
|
||||
.show(ui, |ui| {
|
||||
ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
|
||||
callback(ui, self)
|
||||
});
|
||||
})
|
||||
.response
|
||||
.layer_id;
|
||||
ui.ctx().set_transform_layer(id, transform);
|
||||
ui.ctx().set_sublayer(window_layer, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
use egui::{Pos2, Rect, Scene, Vec2};
|
||||
|
||||
use super::widget_gallery;
|
||||
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct SceneDemo {
|
||||
widget_gallery: widget_gallery::WidgetGallery,
|
||||
scene_rect: Rect,
|
||||
}
|
||||
|
||||
impl Default for SceneDemo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
widget_gallery: Default::default(),
|
||||
scene_rect: Rect::ZERO, // `egui::Scene` will initialize this to something valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Demo for SceneDemo {
|
||||
fn name(&self) -> &'static str {
|
||||
"🔍 Scene"
|
||||
}
|
||||
|
||||
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
|
||||
use crate::View as _;
|
||||
let window = egui::Window::new("Scene")
|
||||
.default_width(300.0)
|
||||
.default_height(300.0)
|
||||
.scroll(false)
|
||||
.open(open);
|
||||
window.show(ctx, |ui| self.ui(ui));
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::View for SceneDemo {
|
||||
fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
ui.label(
|
||||
"You can pan by scrolling, and zoom using cmd-scroll. \
|
||||
Double click on the background to reset view.",
|
||||
);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add(crate::egui_github_link_file!());
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
ui.label(format!("Scene rect: {:#?}", &mut self.scene_rect));
|
||||
|
||||
ui.separator();
|
||||
|
||||
egui::Frame::group(ui.style())
|
||||
.inner_margin(0.0)
|
||||
.show(ui, |ui| {
|
||||
let scene = Scene::new()
|
||||
.max_inner_size([350.0, 1000.0])
|
||||
.zoom_range(0.1..=2.0);
|
||||
|
||||
let mut reset_view = false;
|
||||
let mut inner_rect = Rect::NAN;
|
||||
let response = scene
|
||||
.show(ui, &mut self.scene_rect, |ui| {
|
||||
reset_view = ui.button("Reset view").clicked();
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
self.widget_gallery.ui(ui);
|
||||
|
||||
ui.put(
|
||||
Rect::from_min_size(Pos2::new(0.0, -64.0), Vec2::new(200.0, 16.0)),
|
||||
egui::Label::new("You can put a widget anywhere").selectable(false),
|
||||
);
|
||||
|
||||
inner_rect = ui.min_rect();
|
||||
})
|
||||
.response;
|
||||
|
||||
if reset_view || response.double_clicked() {
|
||||
self.scene_rect = inner_rect;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4eed8890c6d8fa6b97639197f5d1be79a72724a70470c5e5ae4b55e3447b9b88
|
||||
size 35561
|
||||
|
|
@ -33,7 +33,7 @@ impl TSTransform {
|
|||
|
||||
#[inline]
|
||||
/// Creates a new translation that first scales points around
|
||||
/// `(0, 0)`, then translates them.
|
||||
/// `(0, 0)`, then translates them.
|
||||
pub fn new(translation: Vec2, scaling: f32) -> Self {
|
||||
Self {
|
||||
translation,
|
||||
|
|
@ -51,6 +51,11 @@ impl TSTransform {
|
|||
Self::new(Vec2::ZERO, scaling)
|
||||
}
|
||||
|
||||
/// Is this a valid, invertible transform?
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.scaling.is_finite() && self.translation.x.is_finite() && self.scaling != 0.0
|
||||
}
|
||||
|
||||
/// Inverts the transform.
|
||||
///
|
||||
/// ```
|
||||
|
|
|
|||
Loading…
Reference in New Issue