use crate::{
core::plot::Image,
core::{
Plot, PlotInputEvent, PlottingError, ReactiveSubscription, Result, ViewportPoint,
ViewportRect,
},
export::write_rgba_png_atomic,
interactive::{
event::{EventHandler, EventProcessor, InteractionEvent, Point2D, Rectangle, Vector2D},
renderer::{InteractiveRenderOutput, RealTimeRenderer},
state::InteractionState,
},
render::{Color, FontConfig, FontFamily, TextRenderer},
};
use arboard::{Clipboard, ImageData};
use softbuffer::{Context as SoftbufferContext, Surface as SoftbufferSurface};
use std::borrow::Cow;
use winit::{
application::ApplicationHandler,
dpi::{PhysicalPosition, PhysicalSize},
event::{ElementState, MouseButton as WinitMouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy, OwnedDisplayHandle},
keyboard::ModifiersState,
window::{Window, WindowAttributes, WindowId},
};
use std::{
num::NonZeroU32,
sync::Arc,
time::{Duration, Instant},
};
const DRAG_THRESHOLD_PX: f64 = 3.0;
const LINE_SCROLL_DELTA_PX: f64 = 50.0;
const FRAME_INTERVAL: Duration = Duration::from_millis(16);
const MENU_FONT_SIZE: f32 = 13.0;
const MENU_MIN_WIDTH_PX: f64 = 220.0;
const MENU_PADDING_X_PX: f64 = 14.0;
const MENU_PADDING_Y_PX: f64 = 8.0;
const MENU_ITEM_HEIGHT_PX: f64 = 30.0;
const MENU_SEPARATOR_HEIGHT_PX: f64 = 10.0;
const MENU_EDGE_MARGIN_PX: f64 = 8.0;
type WindowSurface = SoftbufferSurface<OwnedDisplayHandle, Arc<Window>>;
type ContextMenuActionHandler =
Arc<dyn Fn(InteractiveContextMenuActionContext) -> Result<()> + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InteractiveAppEvent {
ReactiveUpdate,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ActiveDrag {
LeftPan {
anchor_px: Point2D,
last_px: Point2D,
crossed_threshold: bool,
},
RightZoom {
anchor_px: Point2D,
current_px: Point2D,
crossed_threshold: bool,
zoom_enabled: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum PendingHover {
Hover(Point2D),
Clear,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InteractiveContextMenuItem {
pub id: String,
pub label: String,
pub enabled: bool,
}
impl InteractiveContextMenuItem {
pub fn new<I, L>(id: I, label: L) -> Self
where
I: Into<String>,
L: Into<String>,
{
Self {
id: id.into(),
label: label.into(),
enabled: true,
}
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InteractiveContextMenuConfig {
pub enabled: bool,
pub show_reset_view: bool,
pub show_set_home_view: bool,
pub show_go_to_home_view: bool,
pub show_save_png: bool,
pub show_copy_image: bool,
pub show_copy_cursor_coordinates: bool,
pub show_copy_visible_bounds: bool,
pub custom_items: Vec<InteractiveContextMenuItem>,
}
impl Default for InteractiveContextMenuConfig {
fn default() -> Self {
Self {
enabled: true,
show_reset_view: true,
show_set_home_view: true,
show_go_to_home_view: true,
show_save_png: true,
show_copy_image: true,
show_copy_cursor_coordinates: true,
show_copy_visible_bounds: true,
custom_items: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct InteractiveContextMenuActionContext {
pub action_id: String,
pub visible_bounds: ViewportRect,
pub plot_area_px: ViewportRect,
pub window_size_px: (u32, u32),
pub scale_factor: f32,
pub cursor_position_px: ViewportPoint,
pub cursor_data_position: Option<ViewportPoint>,
pub image: Image,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BuiltinContextMenuAction {
ResetView,
SetCurrentViewAsHome,
GoToHomeView,
SavePng,
CopyImage,
CopyCursorCoordinates,
CopyVisibleBounds,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ContextMenuEntryKind {
Builtin(BuiltinContextMenuAction),
Custom { id: String },
Separator,
}
#[derive(Debug, Clone, PartialEq)]
struct ContextMenuEntry {
kind: ContextMenuEntryKind,
label: String,
enabled: bool,
bounds: Option<Rectangle>,
}
#[derive(Debug, Clone, PartialEq)]
struct ContextMenuState {
panel_bounds: Rectangle,
entries: Vec<ContextMenuEntry>,
hovered_index: Option<usize>,
trigger_position: Point2D,
}
pub struct InteractiveWindow {
window: Option<Arc<Window>>,
surface_context: Option<SoftbufferContext<OwnedDisplayHandle>>,
surface: Option<WindowSurface>,
renderer: RealTimeRenderer,
interaction_state: InteractionState,
event_handler: Box<dyn EventHandler>,
title: String,
resizable: bool,
decorations: bool,
window_size: PhysicalSize<u32>,
scale_factor: f64,
mouse_position: PhysicalPosition<f64>,
active_drag: Option<ActiveDrag>,
pending_hover: Option<PendingHover>,
surface_size: Option<(u32, u32)>,
reactive_subscription: Option<ReactiveSubscription>,
modifiers_state: ModifiersState,
context_menu_config: InteractiveContextMenuConfig,
context_menu_action_handler: Option<ContextMenuActionHandler>,
context_menu: Option<ContextMenuState>,
context_menu_overlay_buffer: Vec<u8>,
home_view_bounds: Option<ViewportRect>,
last_frame_time: Instant,
frame_count: u64,
should_close: bool,
}
impl InteractiveWindow {
fn normalize_scroll_delta(delta: MouseScrollDelta) -> f64 {
match delta {
MouseScrollDelta::LineDelta(_, y) => -(y as f64) * LINE_SCROLL_DELTA_PX,
MouseScrollDelta::PixelDelta(pos) => pos.y,
}
}
pub async fn new(plot: Plot, title: &str, width: u32, height: u32) -> Result<Self> {
let renderer = RealTimeRenderer::new().await?;
let interaction_state = InteractionState {
data_bounds: crate::interactive::event::Rectangle::new(0.0, 0.0, 100.0, 100.0),
screen_bounds: crate::interactive::event::Rectangle::new(
0.0,
0.0,
width as f64,
height as f64,
),
..Default::default()
};
let event_handler = Box::new(DefaultEventHandler::new());
Ok(Self {
window: None,
surface_context: None,
surface: None,
renderer,
interaction_state,
event_handler,
title: title.to_string(),
resizable: true,
decorations: true,
window_size: PhysicalSize::new(width, height),
scale_factor: 1.0,
mouse_position: PhysicalPosition::new(0.0, 0.0),
active_drag: None,
pending_hover: None,
surface_size: None,
reactive_subscription: None,
modifiers_state: ModifiersState::default(),
context_menu_config: InteractiveContextMenuConfig::default(),
context_menu_action_handler: None,
context_menu: None,
context_menu_overlay_buffer: Vec::new(),
home_view_bounds: None,
last_frame_time: Instant::now(),
frame_count: 0,
should_close: false,
})
}
pub fn run(mut self, plot: Plot) -> Result<()> {
let mut event_loop_builder = EventLoop::<InteractiveAppEvent>::with_user_event();
#[cfg(target_os = "macos")]
{
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
event_loop_builder
.with_activation_policy(ActivationPolicy::Regular)
.with_activate_ignoring_other_apps(true);
}
let event_loop = event_loop_builder.build().map_err(|e| {
PlottingError::RenderError(format!("Failed to create event loop: {}", e))
})?;
self.surface_context = Some(
SoftbufferContext::new(event_loop.owned_display_handle()).map_err(|e| {
PlottingError::RenderError(format!(
"Failed to create window surface context: {}",
e
))
})?,
);
self.renderer.set_plot(plot);
self.install_reactive_wakeup(event_loop.create_proxy());
let mut app_handler = InteractiveApp::new(self);
event_loop
.run_app(&mut app_handler)
.map_err(|e| PlottingError::RenderError(format!("Event loop error: {}", e)))?;
Ok(())
}
fn install_reactive_wakeup(&mut self, proxy: EventLoopProxy<InteractiveAppEvent>) {
self.reactive_subscription = self.renderer.subscribe_reactive(move || {
let _ = proxy.send_event(InteractiveAppEvent::ReactiveUpdate);
});
}
fn handle_window_event(&mut self, event: WindowEvent) -> Result<()> {
match event {
WindowEvent::Resized(new_size) => {
self.window_size = new_size;
self.interaction_state.screen_bounds = crate::interactive::event::Rectangle::new(
0.0,
0.0,
new_size.width as f64,
new_size.height as f64,
);
self.apply_plot_input(
PlotInputEvent::Resize {
size_px: (new_size.width, new_size.height),
scale_factor: self.scale_factor as f32,
},
true,
)?;
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.scale_factor = scale_factor;
self.apply_plot_input(
PlotInputEvent::Resize {
size_px: (self.window_size.width, self.window_size.height),
scale_factor: scale_factor as f32,
},
true,
)?;
}
WindowEvent::MouseInput { state, button, .. } => {
let position = self.current_pointer_position();
match (button, state) {
(WinitMouseButton::Left, ElementState::Pressed) => {
self.handle_left_button_pressed(position)?
}
(WinitMouseButton::Left, ElementState::Released) => {
self.handle_left_button_released(position)?
}
(WinitMouseButton::Right, ElementState::Pressed) => {
self.handle_right_button_pressed(position)?
}
(WinitMouseButton::Right, ElementState::Released) => {
self.handle_right_button_released(position)?
}
_ => {}
}
}
WindowEvent::MouseWheel { delta, .. } => {
if self.context_menu.is_none() {
let scroll_delta = Self::normalize_scroll_delta(delta);
self.handle_scroll_delta(scroll_delta)?;
}
}
WindowEvent::CursorMoved { position, .. } => {
self.mouse_position = position;
self.interaction_state.mouse_in_window = true;
self.handle_pointer_moved(Point2D::new(position.x, position.y))?;
}
WindowEvent::KeyboardInput { event, .. } => {
if event.state == ElementState::Pressed {
if let Some(key_string) = self.key_event_to_string(&event) {
self.handle_key_string(&key_string)?;
}
}
}
WindowEvent::ModifiersChanged(modifiers) => {
self.modifiers_state = modifiers.state();
}
WindowEvent::Focused(false) => {
self.modifiers_state = ModifiersState::default();
}
WindowEvent::CursorLeft { .. } => {
self.interaction_state.mouse_in_window = false;
if self.active_drag.is_some() {
self.reset_pointer_state();
self.queue_hover_clear();
} else if self.context_menu.is_some() {
self.update_context_menu_hover(None);
} else {
self.queue_hover_clear();
}
}
WindowEvent::CloseRequested => {
self.should_close = true;
}
WindowEvent::RedrawRequested => {
self.render_frame()?;
}
_ => {}
}
Ok(())
}
fn current_pointer_position(&self) -> Point2D {
Point2D::new(self.mouse_position.x, self.mouse_position.y)
}
fn ensure_surface_initialized(&mut self) -> Result<()> {
if self.surface.is_some() {
return Ok(());
}
let window = self.window.as_ref().ok_or_else(|| {
PlottingError::RenderError(
"Window surface requested before window creation".to_string(),
)
})?;
let context = self.surface_context.as_ref().ok_or_else(|| {
PlottingError::RenderError(
"Window surface requested before context creation".to_string(),
)
})?;
self.surface = Some(
SoftbufferSurface::new(context, Arc::clone(window)).map_err(|e| {
PlottingError::RenderError(format!("Failed to create window surface: {}", e))
})?,
);
self.surface_size = None;
Ok(())
}
fn present_frame(&mut self, frame: &InteractiveRenderOutput) -> Result<()> {
if self.window.is_none() || self.surface_context.is_none() {
return Ok(());
}
self.ensure_surface_initialized()?;
let width = NonZeroU32::new(self.window_size.width.max(1))
.expect("window width is clamped to non-zero");
let height = NonZeroU32::new(self.window_size.height.max(1))
.expect("window height is clamped to non-zero");
let mut menu_overlay_buffer = std::mem::take(&mut self.context_menu_overlay_buffer);
let present_result = (|| -> Result<()> {
let menu_overlay = self.render_context_menu_overlay(&mut menu_overlay_buffer)?;
let surface = self
.surface
.as_mut()
.expect("surface should be initialized");
let surface_size = (width.get(), height.get());
if self.surface_size != Some(surface_size) {
surface.resize(width, height).map_err(|e| {
PlottingError::RenderError(format!("Failed to resize window surface: {}", e))
})?;
self.surface_size = Some(surface_size);
}
let mut buffer = surface.buffer_mut().map_err(|e| {
PlottingError::RenderError(format!("Failed to acquire window buffer: {}", e))
})?;
match frame {
InteractiveRenderOutput::Pixels(pixel_data) => {
copy_rgba_to_softbuffer(pixel_data, &mut buffer)
}
InteractiveRenderOutput::Layers(layers) => {
copy_rgba_to_softbuffer(&layers.base.pixels, &mut buffer);
for overlay in &layers.overlays {
blend_rgba_into_softbuffer(&overlay.pixels, &mut buffer);
}
}
}
if let Some(menu_overlay) = menu_overlay {
blend_rgba_into_softbuffer(menu_overlay, &mut buffer);
}
buffer.present().map_err(|e| {
PlottingError::RenderError(format!("Failed to present window buffer: {}", e))
})
})();
self.context_menu_overlay_buffer = menu_overlay_buffer;
present_result
}
fn restore_visible_bounds(&mut self, visible_bounds: ViewportRect) -> Result<()> {
let changed = self.renderer.restore_visible_bounds(
visible_bounds,
(self.window_size.width, self.window_size.height),
self.scale_factor as f32,
);
if changed {
self.sync_interaction_state_from_session()?;
self.interaction_state.viewport_dirty = true;
self.interaction_state.needs_redraw = true;
}
Ok(())
}
fn capture_visible_view_image(&mut self) -> Result<Image> {
self.flush_pending_hover()?;
let frame = self.renderer.render_interactive(
&self.interaction_state,
self.window_size.width,
self.window_size.height,
self.scale_factor as f32,
)?;
Ok(render_output_to_image(
&frame,
self.window_size.width,
self.window_size.height,
))
}
fn build_action_context(
&mut self,
action_id: String,
cursor_position_px: ViewportPoint,
) -> Result<Option<InteractiveContextMenuActionContext>> {
let Some(snapshot) = self.renderer.viewport_snapshot()? else {
return Ok(None);
};
let cursor_data_position = cursor_data_position(
snapshot.visible_bounds,
snapshot.plot_area,
cursor_position_px,
);
let image = self.capture_visible_view_image()?;
Ok(Some(InteractiveContextMenuActionContext {
action_id,
visible_bounds: snapshot.visible_bounds,
plot_area_px: snapshot.plot_area,
window_size_px: (self.window_size.width, self.window_size.height),
scale_factor: self.scale_factor as f32,
cursor_position_px,
cursor_data_position,
image,
}))
}
fn copy_text_to_clipboard(&self, text: &str) -> Result<()> {
let mut clipboard = Clipboard::new()
.map_err(|err| PlottingError::SystemError(format!("clipboard unavailable: {err}")))?;
clipboard
.set_text(text.to_string())
.map_err(|err| PlottingError::SystemError(format!("failed to copy text: {err}")))
}
fn copy_image_to_clipboard(&self, image: &Image) -> Result<()> {
let mut clipboard = Clipboard::new()
.map_err(|err| PlottingError::SystemError(format!("clipboard unavailable: {err}")))?;
clipboard
.set_image(ImageData {
width: image.width as usize,
height: image.height as usize,
bytes: Cow::Owned(image.pixels.clone()),
})
.map_err(|err| PlottingError::SystemError(format!("failed to copy image: {err}")))
}
fn default_export_filename(&self) -> String {
let mut slug = self
.title
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
slug = slug.trim_matches('-').to_string();
while slug.contains("--") {
slug = slug.replace("--", "-");
}
if slug.is_empty() {
"interactive-plot.png".to_string()
} else {
format!("{slug}.png")
}
}
fn spawn_save_png_dialog(&self, image: Image) -> Result<()> {
let file_name = self.default_export_filename();
let dialog = if let Some(window) = self.window.as_ref() {
rfd::AsyncFileDialog::new()
.add_filter("PNG image", &["png"])
.set_file_name(&file_name)
.set_parent(window.as_ref())
} else {
rfd::AsyncFileDialog::new()
.add_filter("PNG image", &["png"])
.set_file_name(&file_name)
};
std::thread::Builder::new()
.name("ruviz-save-png".to_string())
.spawn(move || {
let Some(file_handle) = block_on_future(dialog.save_file()) else {
return;
};
if let Err(err) = write_rgba_png_atomic(file_handle.path(), &image) {
log::warn!("interactive PNG export failed: {err}");
}
})
.map(|_| ())
.map_err(|err| {
PlottingError::SystemError(format!("failed to spawn save dialog worker: {err}"))
})
}
fn close_context_menu(&mut self) {
if self.context_menu.take().is_some() {
self.interaction_state.needs_redraw = true;
}
}
fn update_context_menu_hover(&mut self, hovered_index: Option<usize>) {
if let Some(menu) = self.context_menu.as_mut() {
if menu.hovered_index != hovered_index {
menu.hovered_index = hovered_index;
self.interaction_state.needs_redraw = true;
}
}
}
fn context_menu_entry_index_at(&self, position: Point2D) -> Option<usize> {
let menu = self.context_menu.as_ref()?;
menu.entries.iter().position(|entry| {
!matches!(entry.kind, ContextMenuEntryKind::Separator)
&& entry.bounds.is_some_and(|bounds| bounds.contains(position))
})
}
fn build_context_menu_entries(
&mut self,
cursor_position_px: ViewportPoint,
) -> Result<Vec<ContextMenuEntry>> {
let snapshot = self.renderer.viewport_snapshot()?;
let cursor_available = snapshot
.as_ref()
.map(|current| {
cursor_data_position(
current.visible_bounds,
current.plot_area,
cursor_position_px,
)
.is_some()
})
.unwrap_or(false);
let viewport_available = snapshot.is_some();
let mut entries = Vec::new();
let mut push_entry = |entries: &mut Vec<ContextMenuEntry>,
kind: ContextMenuEntryKind,
label: &str,
enabled: bool| {
entries.push(ContextMenuEntry {
kind,
label: label.to_string(),
enabled,
bounds: None,
});
};
let mut push_separator = |entries: &mut Vec<ContextMenuEntry>| {
if !entries.is_empty()
&& !matches!(
entries.last().map(|entry| &entry.kind),
Some(ContextMenuEntryKind::Separator)
)
{
entries.push(ContextMenuEntry {
kind: ContextMenuEntryKind::Separator,
label: String::new(),
enabled: false,
bounds: None,
});
}
};
if self.context_menu_config.show_reset_view {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::ResetView),
"Reset View",
viewport_available,
);
}
if self.context_menu_config.show_set_home_view {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::SetCurrentViewAsHome),
"Set Current View As Home",
viewport_available,
);
}
if self.context_menu_config.show_go_to_home_view {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::GoToHomeView),
"Go To Home View",
self.home_view_bounds.is_some(),
);
}
let export_group_enabled = self.context_menu_config.show_save_png
|| self.context_menu_config.show_copy_image
|| self.context_menu_config.show_copy_cursor_coordinates
|| self.context_menu_config.show_copy_visible_bounds;
if !entries.is_empty() && export_group_enabled {
push_separator(&mut entries);
}
if self.context_menu_config.show_save_png {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::SavePng),
"Save PNG...",
true,
);
}
if self.context_menu_config.show_copy_image {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::CopyImage),
"Copy Image",
true,
);
}
if self.context_menu_config.show_copy_cursor_coordinates {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::CopyCursorCoordinates),
"Copy Cursor Coordinates",
cursor_available,
);
}
if self.context_menu_config.show_copy_visible_bounds {
push_entry(
&mut entries,
ContextMenuEntryKind::Builtin(BuiltinContextMenuAction::CopyVisibleBounds),
"Copy Visible Bounds",
viewport_available,
);
}
if !self.context_menu_config.custom_items.is_empty() {
push_separator(&mut entries);
for item in &self.context_menu_config.custom_items {
entries.push(ContextMenuEntry {
kind: ContextMenuEntryKind::Custom {
id: item.id.clone(),
},
label: item.label.clone(),
enabled: item.enabled,
bounds: None,
});
}
}
while matches!(
entries.last().map(|entry| &entry.kind),
Some(ContextMenuEntryKind::Separator)
) {
entries.pop();
}
Ok(entries)
}
fn build_context_menu(&mut self, position: Point2D) -> Result<Option<ContextMenuState>> {
let cursor_position_px = ViewportPoint::new(position.x, position.y);
let mut entries = self.build_context_menu_entries(cursor_position_px)?;
if entries.is_empty() {
return Ok(None);
}
let text_renderer = TextRenderer::new();
let font = FontConfig::new(FontFamily::SansSerif, MENU_FONT_SIZE);
let max_label_width = entries
.iter()
.filter(|entry| !matches!(entry.kind, ContextMenuEntryKind::Separator))
.map(|entry| {
text_renderer
.measure_text(&entry.label, &font)
.map(|(width, _)| width as f64)
.unwrap_or_else(|_| {
entry.label.chars().count() as f64 * f64::from(MENU_FONT_SIZE) * 0.58
})
})
.fold(MENU_MIN_WIDTH_PX - MENU_PADDING_X_PX * 2.0, f64::max);
let panel_width = (max_label_width + MENU_PADDING_X_PX * 2.0).max(MENU_MIN_WIDTH_PX);
let content_height = entries.iter().fold(0.0, |height, entry| {
height
+ if matches!(entry.kind, ContextMenuEntryKind::Separator) {
MENU_SEPARATOR_HEIGHT_PX
} else {
MENU_ITEM_HEIGHT_PX
}
});
let panel_height = content_height + MENU_PADDING_Y_PX * 2.0;
let max_left = (self.window_size.width as f64 - panel_width - MENU_EDGE_MARGIN_PX)
.max(MENU_EDGE_MARGIN_PX);
let max_top = (self.window_size.height as f64 - panel_height - MENU_EDGE_MARGIN_PX)
.max(MENU_EDGE_MARGIN_PX);
let left = position.x.clamp(MENU_EDGE_MARGIN_PX, max_left);
let top = position.y.clamp(MENU_EDGE_MARGIN_PX, max_top);
let panel_bounds = Rectangle::new(left, top, left + panel_width, top + panel_height);
let mut cursor_y = top + MENU_PADDING_Y_PX;
for entry in &mut entries {
let entry_height = if matches!(entry.kind, ContextMenuEntryKind::Separator) {
MENU_SEPARATOR_HEIGHT_PX
} else {
MENU_ITEM_HEIGHT_PX
};
entry.bounds = Some(Rectangle::new(
left,
cursor_y,
left + panel_width,
cursor_y + entry_height,
));
cursor_y += entry_height;
}
let hovered_index = entries.iter().position(|entry| {
!matches!(entry.kind, ContextMenuEntryKind::Separator)
&& entry.bounds.is_some_and(|bounds| bounds.contains(position))
});
Ok(Some(ContextMenuState {
panel_bounds,
entries,
hovered_index,
trigger_position: position,
}))
}
fn open_context_menu(&mut self, position: Point2D) -> Result<()> {
if !self.context_menu_config.enabled {
return Ok(());
}
self.clear_pending_hover();
self.apply_plot_input(PlotInputEvent::ClearHover, false)?;
self.context_menu = self.build_context_menu(position)?;
if self.context_menu.is_some() {
self.interaction_state.needs_redraw = true;
}
Ok(())
}
fn handle_context_menu_pointer_moved(&mut self, position: Point2D) {
let hovered_index = self.context_menu_entry_index_at(position);
self.update_context_menu_hover(hovered_index);
}
fn handle_context_menu_left_click(&mut self, position: Point2D) {
let Some(index) = self.context_menu_entry_index_at(position) else {
self.close_context_menu();
return;
};
let Some(menu) = self.context_menu.as_ref() else {
return;
};
if !menu.entries.get(index).is_some_and(|entry| entry.enabled) {
return;
}
if let Err(err) = self.activate_context_menu_entry(index) {
log::warn!("interactive context menu action failed: {}", err);
}
}
fn activate_context_menu_entry(&mut self, index: usize) -> Result<()> {
let Some(menu) = self.context_menu.as_ref() else {
return Ok(());
};
let Some(entry) = menu.entries.get(index).cloned() else {
return Ok(());
};
let trigger_position_px =
ViewportPoint::new(menu.trigger_position.x, menu.trigger_position.y);
self.close_context_menu();
match entry.kind {
ContextMenuEntryKind::Builtin(action) => {
self.execute_builtin_context_menu_action(action, trigger_position_px)
}
ContextMenuEntryKind::Custom { id } => {
let Some(handler) = self.context_menu_action_handler.clone() else {
return Ok(());
};
let Some(context) = self.build_action_context(id, trigger_position_px)? else {
return Ok(());
};
handler(context)
}
ContextMenuEntryKind::Separator => Ok(()),
}
}
fn execute_builtin_context_menu_action(
&mut self,
action: BuiltinContextMenuAction,
cursor_position_px: ViewportPoint,
) -> Result<()> {
match action {
BuiltinContextMenuAction::ResetView => {
self.reset_pointer_state();
self.apply_plot_input(PlotInputEvent::ResetView, true)
}
BuiltinContextMenuAction::SetCurrentViewAsHome => {
if let Some(snapshot) = self.renderer.viewport_snapshot()? {
self.home_view_bounds = Some(snapshot.visible_bounds);
}
Ok(())
}
BuiltinContextMenuAction::GoToHomeView => {
if let Some(home_view_bounds) = self.home_view_bounds {
self.restore_visible_bounds(home_view_bounds)?;
}
Ok(())
}
BuiltinContextMenuAction::SavePng => {
let image = self.capture_visible_view_image()?;
self.spawn_save_png_dialog(image)
}
BuiltinContextMenuAction::CopyImage => {
let image = self.capture_visible_view_image()?;
self.copy_image_to_clipboard(&image)
}
BuiltinContextMenuAction::CopyCursorCoordinates => {
let Some(snapshot) = self.renderer.viewport_snapshot()? else {
return Ok(());
};
let Some(cursor_data_position) = cursor_data_position(
snapshot.visible_bounds,
snapshot.plot_area,
cursor_position_px,
) else {
return Ok(());
};
self.copy_text_to_clipboard(&format!(
"x={:.6}, y={:.6}",
cursor_data_position.x, cursor_data_position.y
))
}
BuiltinContextMenuAction::CopyVisibleBounds => {
let Some(snapshot) = self.renderer.viewport_snapshot()? else {
return Ok(());
};
self.copy_text_to_clipboard(&format!(
"x=[{:.6}, {:.6}], y=[{:.6}, {:.6}]",
snapshot.visible_bounds.min.x,
snapshot.visible_bounds.max.x,
snapshot.visible_bounds.min.y,
snapshot.visible_bounds.max.y
))
}
}
}
fn render_context_menu_overlay<'a>(
&self,
pixel_data: &'a mut Vec<u8>,
) -> Result<Option<&'a [u8]>> {
let Some(menu) = self.context_menu.as_ref() else {
return Ok(None);
};
let width = self.window_size.width;
let height = self.window_size.height;
let required_len = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
pixel_data.resize(required_len, 0);
pixel_data.fill(0);
fill_rgba_rectangle(
pixel_data,
width,
height,
menu.panel_bounds,
Color::new_rgba(28, 31, 36, 244),
);
draw_rgba_rectangle_outline(
pixel_data,
width,
height,
menu.panel_bounds,
Color::new_rgba(116, 126, 140, 255),
1,
);
for (index, entry) in menu.entries.iter().enumerate() {
match entry.kind {
ContextMenuEntryKind::Separator => {
let y = entry
.bounds
.map(|bounds| bounds.center().y)
.unwrap_or_else(|| {
menu.panel_bounds.min.y
+ MENU_PADDING_Y_PX
+ index as f64 * MENU_SEPARATOR_HEIGHT_PX
});
fill_rgba_rectangle(
pixel_data,
width,
height,
Rectangle::new(
menu.panel_bounds.min.x + MENU_PADDING_X_PX,
y,
menu.panel_bounds.max.x - MENU_PADDING_X_PX,
y + 1.0,
),
Color::new_rgba(74, 84, 96, 200),
);
}
_ => {
let Some(bounds) = entry.bounds else {
continue;
};
if menu.hovered_index == Some(index) && entry.enabled {
fill_rgba_rectangle(
pixel_data,
width,
height,
Rectangle::new(
bounds.min.x + 4.0,
bounds.min.y + 2.0,
bounds.max.x - 4.0,
bounds.max.y - 2.0,
),
Color::new_rgba(56, 86, 120, 220),
);
}
}
}
}
let Some(size) = tiny_skia::IntSize::from_wh(width, height) else {
return Ok(Some(pixel_data.as_slice()));
};
let Some(mut pixmap) = tiny_skia::PixmapMut::from_bytes(
pixel_data.as_mut_slice(),
size.width(),
size.height(),
) else {
return Ok(Some(pixel_data.as_slice()));
};
let text_renderer = TextRenderer::new();
let font = FontConfig::new(FontFamily::SansSerif, MENU_FONT_SIZE);
for entry in &menu.entries {
let Some(bounds) = entry.bounds else {
continue;
};
let text_color = if entry.enabled {
Color::new_rgba(240, 244, 248, 255)
} else {
Color::new_rgba(126, 134, 144, 255)
};
if let Err(err) = text_renderer.render_text_mut(
&mut pixmap,
&entry.label,
(bounds.min.x + MENU_PADDING_X_PX) as f32,
(bounds.min.y + 7.0) as f32,
&font,
text_color,
) {
log::debug!("Skipping context menu text render after rasterization failed: {err}");
}
}
Ok(Some(pixel_data.as_slice()))
}
fn reset_pointer_state(&mut self) {
let had_drag = self.active_drag.take().is_some();
self.active_drag = None;
self.interaction_state.mouse_button_pressed = false;
if had_drag {
self.interaction_state.needs_redraw = true;
}
self.clear_pending_hover();
self.clear_brush_overlay();
}
fn clear_brush_overlay(&mut self) {
let had_brush = self.interaction_state.brushed_region.take().is_some();
self.interaction_state.brushed_region = None;
self.interaction_state.brush_active = false;
self.interaction_state.brush_start = None;
if had_brush {
self.interaction_state.needs_redraw = true;
}
}
fn box_zoom_drag_active(&self) -> bool {
matches!(self.active_drag, Some(ActiveDrag::RightZoom { .. }))
}
fn apply_plot_input(&mut self, event: PlotInputEvent, viewport_dirty: bool) -> Result<()> {
let session_changed = self.renderer.apply_session_input(
event,
(self.window_size.width, self.window_size.height),
self.scale_factor as f32,
);
if session_changed || viewport_dirty {
self.sync_interaction_state_from_session()?;
self.interaction_state.viewport_dirty = viewport_dirty;
self.interaction_state.needs_redraw = true;
}
Ok(())
}
fn clear_pending_hover(&mut self) {
self.pending_hover = None;
}
fn queue_hover(&mut self, position: Point2D) {
self.pending_hover = Some(PendingHover::Hover(position));
self.interaction_state.needs_redraw = true;
}
fn queue_hover_clear(&mut self) {
self.pending_hover = Some(PendingHover::Clear);
self.interaction_state.needs_redraw = true;
}
fn flush_pending_hover(&mut self) -> Result<()> {
let Some(pending_hover) = self.pending_hover.take() else {
return Ok(());
};
match pending_hover {
PendingHover::Hover(position) => self.apply_plot_input(
PlotInputEvent::Hover {
position_px: ViewportPoint::new(position.x, position.y),
},
false,
),
PendingHover::Clear => self.apply_plot_input(PlotInputEvent::ClearHover, false),
}
}
fn sync_interaction_state_from_session(&mut self) -> Result<()> {
let Some(snapshot) = self.renderer.viewport_snapshot()? else {
return Ok(());
};
let preserve_brush_overlay = self.box_zoom_drag_active();
let brushed_region = preserve_brush_overlay
.then_some(self.interaction_state.brushed_region)
.flatten();
let brush_active = preserve_brush_overlay && self.interaction_state.brush_active;
let brush_start = preserve_brush_overlay
.then_some(self.interaction_state.brush_start)
.flatten();
self.interaction_state.zoom_level = snapshot.zoom_level;
self.interaction_state.pan_offset =
Vector2D::new(snapshot.pan_offset.x, snapshot.pan_offset.y);
self.interaction_state.data_bounds = Rectangle::new(
snapshot.base_bounds.min.x,
snapshot.base_bounds.min.y,
snapshot.base_bounds.max.x,
snapshot.base_bounds.max.y,
);
self.interaction_state.screen_bounds = Rectangle::new(
snapshot.plot_area.min.x,
snapshot.plot_area.min.y,
snapshot.plot_area.max.x,
snapshot.plot_area.max.y,
);
self.interaction_state.last_mouse_pos = self.current_pointer_position();
self.interaction_state.hover_point = None;
self.interaction_state.selected_points.clear();
self.interaction_state.brushed_region = brushed_region;
self.interaction_state.brush_active = brush_active;
self.interaction_state.brush_start = brush_start;
self.interaction_state.tooltip_visible = false;
self.interaction_state.tooltip_content.clear();
self.interaction_state.tooltip_position = Point2D::zero();
Ok(())
}
fn plot_area_rect(&self) -> Result<Option<Rectangle>> {
Ok(self.renderer.viewport_snapshot()?.map(|snapshot| {
Rectangle::new(
snapshot.plot_area.min.x,
snapshot.plot_area.min.y,
snapshot.plot_area.max.x,
snapshot.plot_area.max.y,
)
}))
}
fn plot_area_contains(&self, position: Point2D) -> Result<bool> {
let Some(plot_area) = self.plot_area_rect()? else {
return Ok(false);
};
Ok(plot_area.contains(position))
}
fn clamp_to_plot_area(&self, position: Point2D) -> Result<Option<Point2D>> {
let Some(plot_area) = self.plot_area_rect()? else {
return Ok(None);
};
Ok(Some(Point2D::new(
position.x.clamp(plot_area.min.x, plot_area.max.x),
position.y.clamp(plot_area.min.y, plot_area.max.y),
)))
}
fn handle_left_button_pressed(&mut self, position: Point2D) -> Result<()> {
if self.context_menu.is_some() {
self.handle_context_menu_left_click(position);
return Ok(());
}
if !self.plot_area_contains(position)? {
self.reset_pointer_state();
return Ok(());
}
self.clear_pending_hover();
self.active_drag = Some(ActiveDrag::LeftPan {
anchor_px: position,
last_px: position,
crossed_threshold: false,
});
self.interaction_state.last_mouse_pos = position;
self.interaction_state.mouse_button_pressed = true;
Ok(())
}
fn handle_left_button_released(&mut self, position: Point2D) -> Result<()> {
self.interaction_state.last_mouse_pos = position;
let Some(active_drag) = self.active_drag else {
return Ok(());
};
self.reset_pointer_state();
if let ActiveDrag::LeftPan {
anchor_px,
crossed_threshold,
..
} = active_drag
{
if !crossed_threshold && self.plot_area_contains(position)? {
self.apply_plot_input(
PlotInputEvent::SelectAt {
position_px: ViewportPoint::new(position.x, position.y),
},
false,
)?;
}
}
Ok(())
}
fn handle_right_button_pressed(&mut self, position: Point2D) -> Result<()> {
self.close_context_menu();
self.clear_pending_hover();
let zoom_enabled = self.plot_area_contains(position)?;
self.active_drag = Some(ActiveDrag::RightZoom {
anchor_px: position,
current_px: position,
crossed_threshold: false,
zoom_enabled,
});
self.interaction_state.last_mouse_pos = position;
self.interaction_state.mouse_button_pressed = true;
self.interaction_state.brush_start = zoom_enabled.then_some(position);
Ok(())
}
fn handle_right_button_released(&mut self, position: Point2D) -> Result<()> {
self.interaction_state.last_mouse_pos = position;
let Some(active_drag) = self.active_drag else {
if self.context_menu_config.enabled {
self.open_context_menu(position)?;
}
return Ok(());
};
self.reset_pointer_state();
if let ActiveDrag::RightZoom {
anchor_px,
current_px,
crossed_threshold,
zoom_enabled,
} = active_drag
{
if crossed_threshold && zoom_enabled {
let release_px = self.clamp_to_plot_area(position)?.unwrap_or(current_px);
let region_px = ViewportRect::from_points(
ViewportPoint::new(anchor_px.x, anchor_px.y),
ViewportPoint::new(release_px.x, release_px.y),
);
if region_px.width() > f64::EPSILON && region_px.height() > f64::EPSILON {
self.apply_plot_input(PlotInputEvent::ZoomRect { region_px }, true)?;
}
} else if self.context_menu_config.enabled {
self.open_context_menu(position)?;
}
}
Ok(())
}
fn handle_pointer_moved(&mut self, position: Point2D) -> Result<()> {
self.interaction_state.last_mouse_pos = position;
if self.context_menu.is_some() && self.active_drag.is_none() {
self.handle_context_menu_pointer_moved(position);
return Ok(());
}
if let Some(active_drag) = self.active_drag {
self.clear_pending_hover();
match active_drag {
ActiveDrag::LeftPan {
anchor_px,
last_px,
crossed_threshold,
} => {
let crossed_threshold_now =
crossed_threshold || anchor_px.distance_to(position) > DRAG_THRESHOLD_PX;
if crossed_threshold_now {
if !crossed_threshold {
self.apply_plot_input(PlotInputEvent::ClearHover, false)?;
}
let previous = if crossed_threshold {
last_px
} else {
anchor_px
};
let delta_x = position.x - previous.x;
let delta_y = position.y - previous.y;
if delta_x.abs() > f64::EPSILON || delta_y.abs() > f64::EPSILON {
self.active_drag = Some(ActiveDrag::LeftPan {
anchor_px,
last_px: position,
crossed_threshold: true,
});
self.apply_plot_input(
PlotInputEvent::Pan {
delta_px: ViewportPoint::new(delta_x, delta_y),
},
true,
)?;
} else {
self.active_drag = Some(ActiveDrag::LeftPan {
anchor_px,
last_px,
crossed_threshold: true,
});
}
} else {
self.active_drag = Some(ActiveDrag::LeftPan {
anchor_px,
last_px,
crossed_threshold: false,
});
}
return Ok(());
}
ActiveDrag::RightZoom {
anchor_px,
current_px: _,
crossed_threshold,
zoom_enabled,
} => {
let clamped_position = if zoom_enabled {
self.clamp_to_plot_area(position)?.unwrap_or(anchor_px)
} else {
position
};
let crossed_threshold_now =
crossed_threshold || anchor_px.distance_to(position) > DRAG_THRESHOLD_PX;
self.active_drag = Some(ActiveDrag::RightZoom {
anchor_px,
current_px: clamped_position,
crossed_threshold: crossed_threshold_now,
zoom_enabled,
});
if crossed_threshold_now && zoom_enabled {
if !crossed_threshold {
self.apply_plot_input(PlotInputEvent::ClearHover, false)?;
}
self.interaction_state.brush_start = Some(anchor_px);
self.interaction_state.brush_active = true;
self.interaction_state.brushed_region =
Some(Rectangle::from_points(anchor_px, clamped_position));
self.interaction_state.needs_redraw = true;
}
return Ok(());
}
}
}
if self.plot_area_contains(position)? {
self.queue_hover(position);
} else {
self.queue_hover_clear();
}
Ok(())
}
fn handle_scroll_delta(&mut self, delta_y: f64) -> Result<()> {
if delta_y.abs() <= f64::EPSILON {
return Ok(());
}
let position = self.current_pointer_position();
if !self.plot_area_contains(position)? {
return Ok(());
}
let factor = (1.0025f64).powf(-delta_y).clamp(0.25, 4.0);
self.apply_plot_input(
PlotInputEvent::Zoom {
factor,
center_px: ViewportPoint::new(position.x, position.y),
},
true,
)
}
fn handle_key_string(&mut self, key: &str) -> Result<()> {
if let Some(action) = self.builtin_shortcut_action_for_key(key) {
self.close_context_menu();
return self.execute_builtin_context_menu_action(
action,
ViewportPoint::new(self.mouse_position.x, self.mouse_position.y),
);
}
match key {
"Escape" => {
if self.context_menu.is_some() {
self.close_context_menu();
Ok(())
} else {
self.reset_pointer_state();
self.apply_plot_input(PlotInputEvent::ResetView, true)
}
}
"Delete" => self.apply_plot_input(PlotInputEvent::ClearSelection, false),
_ => Ok(()),
}
}
fn primary_shortcut_active(&self) -> bool {
#[cfg(target_os = "macos")]
{
self.modifiers_state.super_key()
}
#[cfg(not(target_os = "macos"))]
{
self.modifiers_state.control_key()
}
}
fn builtin_shortcut_action_for_key(&self, key: &str) -> Option<BuiltinContextMenuAction> {
if !self.primary_shortcut_active() || self.modifiers_state.alt_key() {
return None;
}
match key {
"s" if self.context_menu_config.show_save_png => {
Some(BuiltinContextMenuAction::SavePng)
}
"c" if self.context_menu_config.show_copy_image => {
Some(BuiltinContextMenuAction::CopyImage)
}
_ => None,
}
}
fn process_interaction_event(&mut self, event: InteractionEvent) -> Result<()> {
EventProcessor::validate_event(&event)?;
match event {
InteractionEvent::Zoom { factor, center } => self.apply_plot_input(
PlotInputEvent::Zoom {
factor,
center_px: ViewportPoint::new(center.x, center.y),
},
true,
)?,
InteractionEvent::Pan { delta } => self.apply_plot_input(
PlotInputEvent::Pan {
delta_px: ViewportPoint::new(delta.x, delta.y),
},
true,
)?,
InteractionEvent::Reset => self.apply_plot_input(PlotInputEvent::ResetView, true)?,
InteractionEvent::Select { region } => {
self.apply_plot_input(
PlotInputEvent::BrushStart {
position_px: ViewportPoint::new(region.min.x, region.min.y),
},
false,
)?;
self.apply_plot_input(
PlotInputEvent::BrushMove {
position_px: ViewportPoint::new(region.max.x, region.max.y),
},
false,
)?;
self.apply_plot_input(
PlotInputEvent::BrushEnd {
position_px: ViewportPoint::new(region.max.x, region.max.y),
},
false,
)?;
}
InteractionEvent::SelectPoint { point } => self.apply_plot_input(
PlotInputEvent::SelectAt {
position_px: ViewportPoint::new(point.x, point.y),
},
false,
)?,
InteractionEvent::ClearSelection => {
self.apply_plot_input(PlotInputEvent::ClearSelection, false)?
}
InteractionEvent::Hover { point } => {
if self.plot_area_contains(point)? {
self.apply_plot_input(
PlotInputEvent::Hover {
position_px: ViewportPoint::new(point.x, point.y),
},
false,
)?;
} else {
self.apply_plot_input(PlotInputEvent::ClearHover, false)?;
}
}
InteractionEvent::ShowTooltip { content, position } => self.apply_plot_input(
PlotInputEvent::ShowTooltip {
content,
position_px: ViewportPoint::new(position.x, position.y),
},
false,
)?,
InteractionEvent::HideTooltip => {
self.apply_plot_input(PlotInputEvent::HideTooltip, false)?
}
InteractionEvent::AddAnnotation { annotation } => {
self.interaction_state.add_annotation(annotation);
}
_ => {
self.event_handler.handle_event(event)?;
}
}
Ok(())
}
fn render_frame(&mut self) -> Result<()> {
let frame_start = Instant::now();
let dt = frame_start.duration_since(self.last_frame_time);
self.interaction_state.update_animation(dt);
self.event_handler.update(dt)?;
self.flush_pending_hover()?;
if self.has_pending_redraw() {
let frame = self.renderer.render_interactive(
&self.interaction_state,
self.window_size.width,
self.window_size.height,
self.scale_factor as f32,
)?;
self.present_frame(&frame)?;
self.sync_interaction_state_from_session()?;
self.interaction_state.needs_redraw = false;
self.interaction_state.mark_viewport_clean();
}
self.last_frame_time = frame_start;
self.frame_count += 1;
self.interaction_state.update_frame_timing();
Ok(())
}
fn has_pending_redraw(&self) -> bool {
self.pending_hover.is_some()
|| self.interaction_state.needs_redraw
|| self.event_handler.needs_redraw()
}
fn needs_frame_timer(&self) -> bool {
!matches!(
self.interaction_state.animation_state,
crate::interactive::state::AnimationState::Idle
) || self.event_handler.needs_redraw()
}
fn request_redraw_if_needed(&self) {
if !self.has_pending_redraw() && !self.needs_frame_timer() {
return;
}
if let Some(ref window) = self.window {
window.request_redraw();
}
}
fn key_event_to_string(&self, event: &winit::event::KeyEvent) -> Option<String> {
use winit::keyboard::{Key, NamedKey};
match &event.logical_key {
Key::Named(named_key) => match named_key {
NamedKey::Escape => Some("Escape".to_string()),
NamedKey::Delete => Some("Delete".to_string()),
NamedKey::Space => Some("Space".to_string()),
NamedKey::Enter => Some("Enter".to_string()),
_ => None,
},
Key::Character(ch) => Some(ch.to_lowercase()),
_ => None,
}
}
}
struct InteractiveApp {
window_state: Option<InteractiveWindow>,
}
impl InteractiveApp {
fn new(window: InteractiveWindow) -> Self {
Self {
window_state: Some(window),
}
}
}
impl ApplicationHandler<InteractiveAppEvent> for InteractiveApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if let Some(ref mut window_state) = self.window_state {
if window_state.window.is_none() {
let window_attrs = WindowAttributes::default()
.with_title(window_state.title.clone())
.with_inner_size(window_state.window_size)
.with_resizable(window_state.resizable)
.with_decorations(window_state.decorations)
.with_visible(true)
.with_active(true);
match event_loop.create_window(window_attrs) {
Ok(window) => {
window.set_visible(true);
window.focus_window();
window.request_user_attention(Some(
winit::window::UserAttentionType::Informational,
));
window_state.window = Some(Arc::new(window));
if let Err(err) = window_state.ensure_surface_initialized() {
eprintln!("Failed to initialize window surface: {}", err);
event_loop.exit();
} else {
window_state.request_redraw_if_needed();
}
}
Err(e) => {
eprintln!("Failed to create window: {}", e);
event_loop.exit();
}
}
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
if let Some(ref mut window_state) = self.window_state {
match window_state.handle_window_event(event) {
Ok(()) => {
if window_state.should_close {
event_loop.exit();
} else {
window_state.request_redraw_if_needed();
}
}
Err(e) => {
eprintln!("Error handling window event: {}", e);
event_loop.exit();
}
}
}
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: InteractiveAppEvent) {
if let (InteractiveAppEvent::ReactiveUpdate, Some(window_state)) =
(event, self.window_state.as_mut())
{
window_state.interaction_state.needs_redraw = true;
window_state.request_redraw_if_needed();
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if let Some(ref window_state) = self.window_state {
window_state.request_redraw_if_needed();
if window_state.needs_frame_timer() {
event_loop.set_control_flow(ControlFlow::wait_duration(FRAME_INTERVAL));
return;
}
}
event_loop.set_control_flow(ControlFlow::Wait);
}
}
fn copy_rgba_to_softbuffer(src_rgba: &[u8], dst_rgbx: &mut [u32]) {
for (dst, rgba) in dst_rgbx.iter_mut().zip(src_rgba.chunks_exact(4)) {
let red = rgba[0] as u32;
let green = rgba[1] as u32;
let blue = rgba[2] as u32;
*dst = (red << 16) | (green << 8) | blue;
}
}
fn blend_rgba_into_softbuffer(src_rgba: &[u8], dst_rgbx: &mut [u32]) {
for (dst, rgba) in dst_rgbx.iter_mut().zip(src_rgba.chunks_exact(4)) {
let alpha = rgba[3];
if alpha == 0 {
continue;
}
if alpha == u8::MAX {
let red = rgba[0] as u32;
let green = rgba[1] as u32;
let blue = rgba[2] as u32;
*dst = (red << 16) | (green << 8) | blue;
continue;
}
let dst_red = ((*dst >> 16) & 0xff) as u8;
let dst_green = ((*dst >> 8) & 0xff) as u8;
let dst_blue = (*dst & 0xff) as u8;
let alpha = alpha as f32 / 255.0;
let red = blend_channel(dst_red, rgba[0], alpha) as u32;
let green = blend_channel(dst_green, rgba[1], alpha) as u32;
let blue = blend_channel(dst_blue, rgba[2], alpha) as u32;
*dst = (red << 16) | (green << 8) | blue;
}
}
fn blend_channel(background: u8, foreground: u8, alpha: f32) -> u8 {
let bg = background as f32 / 255.0;
let fg = foreground as f32 / 255.0;
((bg * (1.0 - alpha) + fg * alpha) * 255.0) as u8
}
fn render_output_to_image(frame: &InteractiveRenderOutput, width: u32, height: u32) -> Image {
match frame {
InteractiveRenderOutput::Pixels(pixel_data) => {
Image::new(width, height, pixel_data.clone())
}
InteractiveRenderOutput::Layers(layers) => {
let mut pixels = layers.base.pixels.clone();
for overlay in &layers.overlays {
blend_rgba_into_rgba(&overlay.pixels, &mut pixels);
}
Image::new(width, height, pixels)
}
}
}
fn blend_rgba_into_rgba(src_rgba: &[u8], dst_rgba: &mut [u8]) {
for (src, dst) in src_rgba.chunks_exact(4).zip(dst_rgba.chunks_exact_mut(4)) {
let alpha = src[3];
if alpha == 0 {
continue;
}
if alpha == u8::MAX {
dst.copy_from_slice(src);
continue;
}
let alpha = alpha as f32 / 255.0;
dst[0] = blend_channel(dst[0], src[0], alpha);
dst[1] = blend_channel(dst[1], src[1], alpha);
dst[2] = blend_channel(dst[2], src[2], alpha);
dst[3] = alpha_blend_alpha(dst[3], src[3]);
}
}
fn alpha_blend_alpha(background: u8, foreground: u8) -> u8 {
let bg = background as f32 / 255.0;
let fg = foreground as f32 / 255.0;
((fg + bg * (1.0 - fg)) * 255.0) as u8
}
fn fill_rgba_rectangle(
pixel_data: &mut [u8],
width: u32,
height: u32,
region: Rectangle,
color: Color,
) {
let x1 = region.min.x.floor().max(0.0) as i32;
let y1 = region.min.y.floor().max(0.0) as i32;
let x2 = region.max.x.ceil().min(width as f64) as i32;
let y2 = region.max.y.ceil().min(height as f64) as i32;
if x1 >= x2 || y1 >= y2 {
return;
}
let alpha = color.a as f32 / 255.0;
let width = width as i32;
let height = height as i32;
for y in y1.max(0)..y2.min(height) {
for x in x1.max(0)..x2.min(width) {
let index = ((y * width + x) * 4) as usize;
pixel_data[index] = blend_channel(pixel_data[index], color.r, alpha);
pixel_data[index + 1] = blend_channel(pixel_data[index + 1], color.g, alpha);
pixel_data[index + 2] = blend_channel(pixel_data[index + 2], color.b, alpha);
pixel_data[index + 3] = alpha_blend_alpha(pixel_data[index + 3], color.a);
}
}
}
fn draw_rgba_rectangle_outline(
pixel_data: &mut [u8],
width: u32,
height: u32,
region: Rectangle,
color: Color,
thickness: i32,
) {
let thickness = thickness.max(1) as f64;
fill_rgba_rectangle(
pixel_data,
width,
height,
Rectangle::new(
region.min.x,
region.min.y,
region.max.x,
region.min.y + thickness,
),
color,
);
fill_rgba_rectangle(
pixel_data,
width,
height,
Rectangle::new(
region.min.x,
region.max.y - thickness,
region.max.x,
region.max.y,
),
color,
);
fill_rgba_rectangle(
pixel_data,
width,
height,
Rectangle::new(
region.min.x,
region.min.y,
region.min.x + thickness,
region.max.y,
),
color,
);
fill_rgba_rectangle(
pixel_data,
width,
height,
Rectangle::new(
region.max.x - thickness,
region.min.y,
region.max.x,
region.max.y,
),
color,
);
}
fn cursor_data_position(
visible_bounds: ViewportRect,
plot_area: ViewportRect,
position_px: ViewportPoint,
) -> Option<ViewportPoint> {
if !plot_area.contains(position_px) {
return None;
}
let plot_width = plot_area.width();
let plot_height = plot_area.height();
if plot_width <= f64::EPSILON || plot_height <= f64::EPSILON {
return None;
}
let normalized_x = ((position_px.x - plot_area.min.x) / plot_width).clamp(0.0, 1.0);
let normalized_y = ((position_px.y - plot_area.min.y) / plot_height).clamp(0.0, 1.0);
Some(ViewportPoint::new(
visible_bounds.min.x + visible_bounds.width() * normalized_x,
visible_bounds.max.y - visible_bounds.height() * normalized_y,
))
}
fn block_on_future<F>(future: F) -> F::Output
where
F: std::future::Future,
{
use std::{
sync::Arc,
task::{Context, Poll, Wake, Waker},
};
struct ThreadWaker(std::thread::Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(result) => return result,
Poll::Pending => std::thread::park(),
}
}
}
#[allow(clippy::type_complexity)] struct DefaultEventHandler {
custom_handlers: Vec<Box<dyn Fn(&InteractionEvent) -> Result<()>>>,
}
impl DefaultEventHandler {
fn new() -> Self {
Self {
custom_handlers: Vec::new(),
}
}
fn add_custom_handler<F>(&mut self, handler: F)
where
F: Fn(&InteractionEvent) -> Result<()> + 'static,
{
self.custom_handlers.push(Box::new(handler));
}
}
impl EventHandler for DefaultEventHandler {
fn handle_event(
&mut self,
event: InteractionEvent,
) -> Result<crate::interactive::event::EventResponse> {
for handler in &self.custom_handlers {
handler(&event)?;
}
match event {
InteractionEvent::AddAnnotation { .. } => {
Ok(crate::interactive::event::EventResponse::NeedsRedraw)
}
InteractionEvent::ModifyStyle { .. } => {
Ok(crate::interactive::event::EventResponse::NeedsRecompute)
}
_ => Ok(crate::interactive::event::EventResponse::Handled),
}
}
fn update(&mut self, _dt: Duration) -> Result<()> {
Ok(())
}
fn needs_redraw(&self) -> bool {
false }
fn reset(&mut self) {
}
}
pub struct InteractiveWindowBuilder {
title: String,
width: u32,
height: u32,
resizable: bool,
decorations: bool,
context_menu_config: InteractiveContextMenuConfig,
context_menu_action_handler: Option<ContextMenuActionHandler>,
}
impl Default for InteractiveWindowBuilder {
fn default() -> Self {
Self {
title: "Interactive Plot".to_string(),
width: 800,
height: 600,
resizable: true,
decorations: true,
context_menu_config: InteractiveContextMenuConfig::default(),
context_menu_action_handler: None,
}
}
}
impl InteractiveWindowBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn title<S: Into<String>>(mut self, title: S) -> Self {
self.title = title.into();
self
}
pub fn size(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
pub fn decorations(mut self, decorations: bool) -> Self {
self.decorations = decorations;
self
}
pub fn context_menu(mut self, config: InteractiveContextMenuConfig) -> Self {
self.context_menu_config = config;
self
}
pub fn on_context_menu_action<F>(mut self, handler: F) -> Self
where
F: Fn(InteractiveContextMenuActionContext) -> Result<()> + Send + Sync + 'static,
{
self.context_menu_action_handler = Some(Arc::new(handler));
self
}
pub async fn build(self, plot: Plot) -> Result<InteractiveWindow> {
if self.context_menu_config.enabled
&& !self.context_menu_config.custom_items.is_empty()
&& self.context_menu_action_handler.is_none()
{
return Err(PlottingError::InvalidInput(
"interactive context menu custom items require on_context_menu_action(...)"
.to_string(),
));
}
let mut window = InteractiveWindow::new(plot, &self.title, self.width, self.height).await?;
window.resizable = self.resizable;
window.decorations = self.decorations;
window.context_menu_config = self.context_menu_config;
window.context_menu_action_handler = self.context_menu_action_handler;
Ok(window)
}
}
pub async fn show_interactive(plot: Plot) -> Result<()> {
let window = InteractiveWindowBuilder::new()
.title("Interactive Plot")
.size(1024, 768)
.build(plot.clone())
.await?;
window.run(plot)
}
#[cfg(test)]
mod tests;