core: Initial Debug UI
This commit is contained in:
parent
eb70a4f361
commit
e12e2a2e54
|
@ -3696,6 +3696,7 @@ dependencies = [
|
|||
"clap",
|
||||
"dasp",
|
||||
"downcast-rs",
|
||||
"egui",
|
||||
"encoding_rs",
|
||||
"enumset",
|
||||
"flash-lso",
|
||||
|
|
|
@ -51,6 +51,7 @@ realfft = "3.3.0"
|
|||
hashbrown = { version = "0.13.2", features = ["raw"] }
|
||||
scopeguard = "1.1.0"
|
||||
fluent-templates = "0.8.0"
|
||||
egui = { version = "0.22.0", optional = true }
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies.futures]
|
||||
version = "0.3.28"
|
||||
|
|
|
@ -26,7 +26,7 @@ use crate::stub::StubCollection;
|
|||
use crate::tag_utils::{SwfMovie, SwfSlice};
|
||||
use crate::timer::Timers;
|
||||
use core::fmt;
|
||||
use gc_arena::{Collect, MutationContext};
|
||||
use gc_arena::{Collect, DynamicRootSet, MutationContext};
|
||||
use instant::Instant;
|
||||
use rand::rngs::SmallRng;
|
||||
use ruffle_render::backend::RenderBackend;
|
||||
|
@ -216,6 +216,9 @@ pub struct UpdateContext<'a, 'gc> {
|
|||
|
||||
/// Manager of in-progress media streams.
|
||||
pub stream_manager: &'a mut StreamManager<'gc>,
|
||||
|
||||
/// Dynamic root for allowing handles to GC objects to exist outside of the GC.
|
||||
pub dynamic_root: &'a mut DynamicRootSet<'gc>,
|
||||
}
|
||||
|
||||
/// Convenience methods for controlling audio.
|
||||
|
@ -373,6 +376,7 @@ impl<'a, 'gc> UpdateContext<'a, 'gc> {
|
|||
actions_since_timeout_check: self.actions_since_timeout_check,
|
||||
frame_phase: self.frame_phase,
|
||||
stream_manager: self.stream_manager,
|
||||
dynamic_root: self.dynamic_root,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
mod display_object;
|
||||
mod handle;
|
||||
|
||||
use crate::context::UpdateContext;
|
||||
use crate::debug_ui::display_object::DisplayObjectWindow;
|
||||
use crate::debug_ui::handle::DisplayObjectHandle;
|
||||
use hashbrown::HashMap;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DebugUi {
|
||||
display_objects: HashMap<DisplayObjectHandle, DisplayObjectWindow>,
|
||||
queued_messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Message {
|
||||
TrackDisplayObject(DisplayObjectHandle),
|
||||
TrackStage,
|
||||
}
|
||||
|
||||
impl DebugUi {
|
||||
pub fn show(&mut self, egui_ctx: &egui::Context, context: &mut UpdateContext) {
|
||||
let mut messages = std::mem::take(&mut self.queued_messages);
|
||||
self.display_objects.retain(|object, window| {
|
||||
let object = object.fetch(context);
|
||||
window.show(egui_ctx, context, object, &mut messages)
|
||||
});
|
||||
for message in messages {
|
||||
match message {
|
||||
Message::TrackDisplayObject(object) => self.track_display_object(object),
|
||||
Message::TrackStage => {
|
||||
self.track_display_object(DisplayObjectHandle::new(context, context.stage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_message(&mut self, message: Message) {
|
||||
self.queued_messages.push(message);
|
||||
}
|
||||
|
||||
pub fn track_display_object(&mut self, handle: DisplayObjectHandle) {
|
||||
self.display_objects.insert(handle, Default::default());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,233 @@
|
|||
use crate::context::UpdateContext;
|
||||
use crate::debug_ui::handle::DisplayObjectHandle;
|
||||
use crate::debug_ui::Message;
|
||||
use crate::display_object::{DisplayObject, TDisplayObject, TDisplayObjectContainer};
|
||||
use egui::{CollapsingHeader, ComboBox, Grid, Id, Ui, Window};
|
||||
use std::borrow::Cow;
|
||||
use swf::BlendMode;
|
||||
|
||||
const ALL_BLEND_MODES: [BlendMode; 14] = [
|
||||
BlendMode::Normal,
|
||||
BlendMode::Layer,
|
||||
BlendMode::Multiply,
|
||||
BlendMode::Screen,
|
||||
BlendMode::Lighten,
|
||||
BlendMode::Darken,
|
||||
BlendMode::Difference,
|
||||
BlendMode::Add,
|
||||
BlendMode::Subtract,
|
||||
BlendMode::Invert,
|
||||
BlendMode::Alpha,
|
||||
BlendMode::Erase,
|
||||
BlendMode::Overlay,
|
||||
BlendMode::HardLight,
|
||||
];
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Default, Copy, Clone)]
|
||||
enum Panel {
|
||||
#[default]
|
||||
Position,
|
||||
Display,
|
||||
Children,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DisplayObjectWindow {
|
||||
open_panel: Panel,
|
||||
}
|
||||
|
||||
impl DisplayObjectWindow {
|
||||
pub fn show<'gc>(
|
||||
&mut self,
|
||||
egui_ctx: &egui::Context,
|
||||
context: &mut UpdateContext<'_, 'gc>,
|
||||
object: DisplayObject<'gc>,
|
||||
messages: &mut Vec<Message>,
|
||||
) -> bool {
|
||||
let mut keep_open = true;
|
||||
Window::new(summary_name(object))
|
||||
.id(Id::new(object.as_ptr()))
|
||||
.open(&mut keep_open)
|
||||
.show(egui_ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.open_panel, Panel::Position, "Position");
|
||||
ui.selectable_value(&mut self.open_panel, Panel::Display, "Display");
|
||||
if let Some(ctr) = object.as_container() {
|
||||
if !ctr.is_empty() {
|
||||
ui.selectable_value(
|
||||
&mut self.open_panel,
|
||||
Panel::Children,
|
||||
format!("Children ({})", ctr.num_children()),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
match self.open_panel {
|
||||
Panel::Position => self.show_position(ui, object),
|
||||
Panel::Display => self.show_display(ui, context, object, messages),
|
||||
Panel::Children => self.show_children(ui, context, object, messages),
|
||||
}
|
||||
});
|
||||
keep_open
|
||||
}
|
||||
|
||||
pub fn show_display<'gc>(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
context: &mut UpdateContext<'_, 'gc>,
|
||||
object: DisplayObject<'gc>,
|
||||
messages: &mut Vec<Message>,
|
||||
) {
|
||||
Grid::new(ui.id().with("display"))
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
if let Some(parent) = object.parent() {
|
||||
ui.label("Parent");
|
||||
if ui.button(summary_name(parent)).clicked() {
|
||||
messages.push(Message::TrackDisplayObject(DisplayObjectHandle::new(
|
||||
context, parent,
|
||||
)));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
let was_visible = object.visible();
|
||||
let mut is_visible = was_visible;
|
||||
ui.label("Visibility");
|
||||
ui.checkbox(&mut is_visible, "Visible");
|
||||
ui.end_row();
|
||||
if is_visible != was_visible {
|
||||
object.set_visible(context.gc_context, is_visible);
|
||||
}
|
||||
|
||||
ui.label("Blend mode");
|
||||
let old_blend = object.blend_mode();
|
||||
let mut new_blend = old_blend;
|
||||
ComboBox::from_id_source(ui.id().with("blendmode"))
|
||||
.selected_text(blend_mode_name(old_blend))
|
||||
.show_ui(ui, |ui| {
|
||||
for mode in ALL_BLEND_MODES {
|
||||
ui.selectable_value(&mut new_blend, mode, blend_mode_name(mode));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
if new_blend != old_blend {
|
||||
object.set_blend_mode(context.gc_context, new_blend);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_position(&mut self, ui: &mut Ui, object: DisplayObject<'_>) {
|
||||
Grid::new(ui.id().with("position"))
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
ui.label("Name");
|
||||
// &mut of a temporary thing because we don't want to actually be able to change this
|
||||
// If we disable it, the user can't highlight or interact with it, so this makes it readonly but enabled
|
||||
ui.text_edit_singleline(&mut object.name().to_string());
|
||||
ui.end_row();
|
||||
|
||||
ui.label("World Bounds");
|
||||
ui.label(object.world_bounds().to_string());
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Local Bounds");
|
||||
ui.label(object.local_bounds().to_string());
|
||||
ui.end_row();
|
||||
|
||||
let base = object.base();
|
||||
let matrix = base.matrix();
|
||||
ui.label("Local Position");
|
||||
ui.label(format!("{}, {}", matrix.tx, matrix.ty));
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Local Rotation");
|
||||
ui.label(format!("{}, {}", matrix.b, matrix.c));
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Local Scale");
|
||||
ui.label(format!("{}, {}", matrix.a, matrix.d));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_children<'gc>(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
context: &mut UpdateContext<'_, 'gc>,
|
||||
object: DisplayObject<'gc>,
|
||||
messages: &mut Vec<Message>,
|
||||
) {
|
||||
if let Some(ctr) = object.as_container() {
|
||||
for child in ctr.iter_render_list() {
|
||||
show_display_tree(ui, context, child, messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_display_tree<'gc>(
|
||||
ui: &mut Ui,
|
||||
context: &mut UpdateContext<'_, 'gc>,
|
||||
object: DisplayObject<'gc>,
|
||||
messages: &mut Vec<Message>,
|
||||
) {
|
||||
CollapsingHeader::new(summary_name(object))
|
||||
.id_source(ui.id().with(object.as_ptr()))
|
||||
.show(ui, |ui| {
|
||||
if ui.button("Track").clicked() {
|
||||
messages.push(Message::TrackDisplayObject(DisplayObjectHandle::new(
|
||||
context, object,
|
||||
)));
|
||||
}
|
||||
if let Some(ctr) = object.as_container() {
|
||||
for child in ctr.iter_render_list() {
|
||||
show_display_tree(ui, context, child, messages);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn summary_name(object: DisplayObject) -> Cow<str> {
|
||||
let do_type = match object {
|
||||
DisplayObject::Stage(_) => "Stage",
|
||||
DisplayObject::Bitmap(_) => "Bitmap",
|
||||
DisplayObject::Avm1Button(_) => "Avm1Button",
|
||||
DisplayObject::Avm2Button(_) => "Avm2Button",
|
||||
DisplayObject::EditText(_) => "EditText",
|
||||
DisplayObject::Graphic(_) => "Graphic",
|
||||
DisplayObject::MorphShape(_) => "MorphShape",
|
||||
DisplayObject::MovieClip(_) => "MovieClip",
|
||||
DisplayObject::Text(_) => "Text",
|
||||
DisplayObject::Video(_) => "Video",
|
||||
DisplayObject::LoaderDisplay(_) => "LoaderDisplay",
|
||||
};
|
||||
|
||||
let name = object.name();
|
||||
if name.is_empty() {
|
||||
Cow::Borrowed(do_type)
|
||||
} else {
|
||||
Cow::Owned(format!("{do_type} \"{name}\""))
|
||||
}
|
||||
}
|
||||
|
||||
fn blend_mode_name(mode: BlendMode) -> &'static str {
|
||||
match mode {
|
||||
BlendMode::Normal => "Normal",
|
||||
BlendMode::Layer => "Layer",
|
||||
BlendMode::Multiply => "Multiply",
|
||||
BlendMode::Screen => "Screen",
|
||||
BlendMode::Lighten => "Lighten",
|
||||
BlendMode::Darken => "Darken",
|
||||
BlendMode::Difference => "Difference",
|
||||
BlendMode::Add => "Add",
|
||||
BlendMode::Subtract => "Subtract",
|
||||
BlendMode::Invert => "Invert",
|
||||
BlendMode::Alpha => "Alpha",
|
||||
BlendMode::Erase => "Erase",
|
||||
BlendMode::Overlay => "Overlay",
|
||||
BlendMode::HardLight => "HardLight",
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
use crate::context::UpdateContext;
|
||||
use crate::display_object::{DisplayObject, DisplayObjectPtr, TDisplayObject};
|
||||
use gc_arena::{DynamicRoot, Rootable};
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
// TODO: Make this generic somehow, we'll want AVM1 and AVM2 object handles too
|
||||
#[derive(Clone)]
|
||||
pub struct DisplayObjectHandle {
|
||||
root: DynamicRoot<Rootable![DisplayObject<'gc>]>,
|
||||
ptr: *const DisplayObjectPtr,
|
||||
}
|
||||
|
||||
impl DisplayObjectHandle {
|
||||
pub fn new<'gc>(
|
||||
context: &mut UpdateContext<'_, 'gc>,
|
||||
object: impl Into<DisplayObject<'gc>>,
|
||||
) -> Self {
|
||||
let object = object.into();
|
||||
Self {
|
||||
root: context.dynamic_root.stash(context.gc_context, object),
|
||||
ptr: object.as_ptr(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch<'gc>(&self, context: &mut UpdateContext<'_, 'gc>) -> DisplayObject<'gc> {
|
||||
*context.dynamic_root.fetch(&self.root)
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for DisplayObjectHandle {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("DisplayObjectHandle")
|
||||
.field(&self.ptr)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<DisplayObjectHandle> for DisplayObjectHandle {
|
||||
#[inline(always)]
|
||||
fn eq(&self, other: &DisplayObjectHandle) -> bool {
|
||||
self.ptr == other.ptr
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for DisplayObjectHandle {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.ptr.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for DisplayObjectHandle {}
|
|
@ -17,6 +17,7 @@ use ruffle_macros::enum_trait_object;
|
|||
use ruffle_render::transform::Transform;
|
||||
use std::cell::{Ref, RefMut};
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::sync::Arc;
|
||||
use swf::{BlendMode, ColorTransform, Fixed8};
|
||||
|
||||
|
|
|
@ -48,6 +48,8 @@ mod xml;
|
|||
pub mod backend;
|
||||
pub mod compatibility_rules;
|
||||
pub mod config;
|
||||
#[cfg(feature = "egui")]
|
||||
pub mod debug_ui;
|
||||
pub mod external;
|
||||
pub mod i18n;
|
||||
pub mod stub;
|
||||
|
|
|
@ -46,7 +46,7 @@ use crate::stub::StubCollection;
|
|||
use crate::tag_utils::SwfMovie;
|
||||
use crate::timer::Timers;
|
||||
use crate::vminterface::Instantiator;
|
||||
use gc_arena::{ArenaParameters, Collect, GcCell};
|
||||
use gc_arena::{ArenaParameters, Collect, DynamicRootSet, GcCell};
|
||||
use gc_arena::{MutationContext, Rootable};
|
||||
use instant::Instant;
|
||||
use rand::{rngs::SmallRng, SeedableRng};
|
||||
|
@ -160,6 +160,9 @@ struct GcRootData<'gc> {
|
|||
|
||||
/// List of actively playing streams to decode.
|
||||
stream_manager: StreamManager<'gc>,
|
||||
|
||||
/// Dynamic root for allowing handles to GC objects to exist outside of the GC.
|
||||
dynamic_root: DynamicRootSet<'gc>,
|
||||
}
|
||||
|
||||
impl<'gc> GcRootData<'gc> {
|
||||
|
@ -185,6 +188,7 @@ impl<'gc> GcRootData<'gc> {
|
|||
&mut ExternalInterface<'gc>,
|
||||
&mut AudioManager<'gc>,
|
||||
&mut StreamManager<'gc>,
|
||||
&mut DynamicRootSet<'gc>,
|
||||
) {
|
||||
(
|
||||
self.stage,
|
||||
|
@ -203,6 +207,7 @@ impl<'gc> GcRootData<'gc> {
|
|||
&mut self.external_interface,
|
||||
&mut self.audio_manager,
|
||||
&mut self.stream_manager,
|
||||
&mut self.dynamic_root,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -313,6 +318,10 @@ pub struct Player {
|
|||
|
||||
/// Any compatibility rules to apply for this movie.
|
||||
compatibility_rules: CompatibilityRules,
|
||||
|
||||
/// Debug UI windows
|
||||
#[cfg(feature = "egui")]
|
||||
debug_ui: Rc<RefCell<crate::debug_ui::DebugUi>>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
|
@ -1750,6 +1759,7 @@ impl Player {
|
|||
external_interface,
|
||||
audio_manager,
|
||||
stream_manager,
|
||||
dynamic_root,
|
||||
) = root_data.update_context_params();
|
||||
|
||||
let mut update_context = UpdateContext {
|
||||
|
@ -1799,6 +1809,7 @@ impl Player {
|
|||
frame_phase: &mut self.frame_phase,
|
||||
stub_tracker: &mut self.stub_tracker,
|
||||
stream_manager,
|
||||
dynamic_root,
|
||||
};
|
||||
|
||||
let prev_frame_rate = *update_context.frame_rate;
|
||||
|
@ -1845,6 +1856,23 @@ impl Player {
|
|||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "egui")]
|
||||
pub fn show_debug_ui(&mut self, egui_ctx: &egui::Context) {
|
||||
// To allow using `mutate_with_update_context` and passing the context inside the debug ui,
|
||||
// we avoid borrowing directly from self here.
|
||||
// This method should only be called once and it will panic if it tries to recursively render.
|
||||
let debug_ui = self.debug_ui.clone();
|
||||
let mut debug_ui = debug_ui.borrow_mut();
|
||||
self.mutate_with_update_context(|context| {
|
||||
debug_ui.show(egui_ctx, context);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "egui")]
|
||||
pub fn debug_ui_message(&mut self, message: crate::debug_ui::Message) {
|
||||
self.debug_ui.borrow_mut().queue_message(message)
|
||||
}
|
||||
|
||||
/// Update the current state of the player.
|
||||
///
|
||||
/// The given function will be called with the current stage root, current
|
||||
|
@ -2203,6 +2231,7 @@ impl PlayerBuilder {
|
|||
gc_context,
|
||||
interner: &mut interner,
|
||||
};
|
||||
let dynamic_root = DynamicRootSet::new(gc_context);
|
||||
|
||||
GcRoot {
|
||||
callstack: GcCell::allocate(gc_context, GcCallstack::default()),
|
||||
|
@ -2228,6 +2257,7 @@ impl PlayerBuilder {
|
|||
timers: Timers::new(),
|
||||
unbound_text_fields: Vec::new(),
|
||||
stream_manager: StreamManager::new(),
|
||||
dynamic_root,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
@ -2317,6 +2347,8 @@ impl PlayerBuilder {
|
|||
spoofed_url: self.spoofed_url.clone(),
|
||||
compatibility_rules: self.compatibility_rules.clone(),
|
||||
stub_tracker: StubCollection::new(),
|
||||
#[cfg(feature = "egui")]
|
||||
debug_ui: Default::default(),
|
||||
|
||||
// GC data
|
||||
gc_arena: Rc::new(RefCell::new(GcArena::new(
|
||||
|
|
|
@ -13,7 +13,7 @@ cpal = "0.15.2"
|
|||
egui = "0.22.0"
|
||||
egui-wgpu = { version = "0.22.0", features = ["winit"] }
|
||||
egui-winit = "0.22.0"
|
||||
ruffle_core = { path = "../core", features = ["audio", "clap", "mp3", "nellymoser", "default_compatibility_rules"] }
|
||||
ruffle_core = { path = "../core", features = ["audio", "clap", "mp3", "nellymoser", "default_compatibility_rules", "egui"] }
|
||||
ruffle_render = { path = "../render", features = ["clap"] }
|
||||
ruffle_render_wgpu = { path = "../render/wgpu", features = ["clap"] }
|
||||
ruffle_video_software = { path = "../video/software", optional = true }
|
||||
|
|
|
@ -22,4 +22,7 @@ help-menu-join-discord = Join Discord
|
|||
help-menu-report-a-bug = Report a Bug...
|
||||
help-menu-sponsor-development = Sponsor Development...
|
||||
help-menu-translate-ruffle = Translate Ruffle...
|
||||
help-menu-about = About Ruffle
|
||||
help-menu-about = About Ruffle
|
||||
|
||||
debug-menu = Debug Tools
|
||||
debug-menu-open-stage = View Stage Info
|
|
@ -14,6 +14,7 @@ use egui::*;
|
|||
use fluent_templates::fluent_bundle::FluentValue;
|
||||
use fluent_templates::{static_loader, Loader};
|
||||
use ruffle_core::backend::ui::US_ENGLISH;
|
||||
use ruffle_core::debug_ui::Message as DebugMessage;
|
||||
use ruffle_core::Player;
|
||||
use std::collections::HashMap;
|
||||
use sys_locale::get_locale;
|
||||
|
@ -105,6 +106,10 @@ impl RuffleGui {
|
|||
|
||||
self.as3_warning(egui_ctx);
|
||||
|
||||
if let Some(player) = player {
|
||||
player.show_debug_ui(egui_ctx);
|
||||
}
|
||||
|
||||
if !self.context_menu.is_empty() {
|
||||
self.context_menu(egui_ctx);
|
||||
}
|
||||
|
@ -194,6 +199,16 @@ impl RuffleGui {
|
|||
}
|
||||
});
|
||||
});
|
||||
menu::menu_button(ui, text(&self.locale, "debug-menu"), |ui| {
|
||||
ui.add_enabled_ui(player.is_some(), |ui| {
|
||||
if Button::new(text(&self.locale, "debug-menu-open-stage")).ui(ui).clicked() {
|
||||
ui.close_menu();
|
||||
if let Some(player) = player {
|
||||
player.debug_ui_message(DebugMessage::TrackStage);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
menu::menu_button(ui, text(&self.locale, "help-menu"), |ui| {
|
||||
if ui.button(text(&self.locale, "help-menu-join-discord")).clicked() {
|
||||
self.launch_website(ui, "https://discord.gg/ruffle");
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use crate::{types::point::Coordinate as PointCoordinate, Point, Twips};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
pub trait Coordinate: PointCoordinate + Ord {
|
||||
const INVALID: Self;
|
||||
|
@ -127,3 +128,16 @@ impl<T: Coordinate> Default for Rectangle<T> {
|
|||
Self::INVALID
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Display for Rectangle<T>
|
||||
where
|
||||
T: Display + Coordinate,
|
||||
{
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}, {} to {}, {}",
|
||||
self.x_min, self.y_min, self.x_max, self.y_max
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue