use egui::{Event, Pos2, TouchDeviceId, TouchId, TouchPhase};
pub(crate) const TARGET: f32 = 44.0;
pub(super) const CURSOR_OFFSET: f64 = 36.0;
pub(crate) fn enabled(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<bool>(egui::Id::new("brep-touch-input"))).unwrap_or(false)
}
pub(crate) fn compact(ctx: &egui::Context) -> bool {
#[cfg(target_arch = "wasm32")]
{
enabled(ctx)
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = ctx; false }
}
pub(crate) fn target_size(ctx: &egui::Context, desktop: egui::Vec2) -> egui::Vec2 {
if enabled(ctx) { desktop.max(egui::Vec2::splat(TARGET)) } else { desktop }
}
pub(crate) fn text_edit_margin(ui: &egui::Ui) -> egui::Margin {
let pad = if enabled(ui.ctx()) { ((TARGET - ui.text_style_height(&egui::TextStyle::Body)) * 0.5).ceil().max(2.0) as i8 } else { 2 };
egui::Margin::symmetric(4, pad)
}
pub(crate) fn prepare_browser_input(ui: &mut egui::Ui) {
#[cfg(target_arch = "wasm32")]
let active = web_sys::window().and_then(|w| w.document())
.and_then(|d| d.document_element())
.is_some_and(|e| e.has_attribute("data-brep-touch"));
#[cfg(not(target_arch = "wasm32"))]
let active = false;
apply_sizing(ui, active);
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn publish_editor(ctx: &egui::Context) {
use wasm_bindgen::{JsCast, JsValue};
if !enabled(ctx) { return; }
if let Some(rect) = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new("brep-mobile-fullscreen-slot"))) {
if let Some(window) = web_sys::window() {
if let Ok(callback) = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("__brepMobileFullscreenRect")) {
if let Ok(callback) = callback.dyn_into::<js_sys::Function>() {
let zoom = ctx.zoom_factor();
let style = ctx.global_style();
let payload = serde_json::json!({ "x": rect.left() * zoom, "y": rect.top() * zoom,
"bg": style.visuals.widgets.inactive.bg_fill.to_array(), "fg": style.visuals.text_color().to_array(),
"visible": ctx.memory(|m| m.top_modal_layer().is_none()) }).to_string();
let _ = callback.call1(&JsValue::NULL, &JsValue::from_str(&payload));
}
}
}
}
let focused = ctx.memory(|m| m.focused());
let selection = focused.and_then(|id| egui::TextEdit::load_state(ctx, id))
.and_then(|state| state.cursor.char_range()).map(|range| range.as_sorted_char_range());
let payload = ctx.output(|output| {
if output.ime.is_none() || focused.is_none() { return "null".to_string(); }
let text = output.events.iter().rev().find_map(|event| event.widget_info().current_text_value.clone());
let reopen = output.events.iter().any(|event| {
event.widget_info().current_text_value.is_some() && matches!(event,
egui::output::OutputEvent::Clicked(_) | egui::output::OutputEvent::DoubleClicked(_) |
egui::output::OutputEvent::FocusGained(_))
});
serde_json::json!({ "id": format!("{:?}", focused.unwrap()), "text": text, "reopen": reopen,
"selection": selection.map(|range| [range.start, range.end]) }).to_string()
});
let cache = egui::Id::new("brep-mobile-editor-output");
if ctx.data(|d| d.get_temp::<String>(cache)).as_deref() == Some(&payload) { return; }
if let Some(window) = web_sys::window() {
if let Ok(callback) = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("__brepMobileEditor")) {
if let Ok(callback) = callback.dyn_into::<js_sys::Function>() {
if callback.call1(&JsValue::NULL, &JsValue::from_str(&payload)).is_ok() {
ctx.data_mut(|d| d.insert_temp(cache, payload));
}
}
}
}
}
fn apply_sizing(ui: &mut egui::Ui, active: bool) {
let ctx = ui.ctx().clone();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("brep-touch-input"), active));
let key = egui::Id::new(("brep-touch-desktop-spacing", format!("{:?}", ctx.theme())));
let saved = ctx.data(|d| d.get_temp::<egui::style::Spacing>(key));
if active {
let mut spacing = saved.unwrap_or_else(|| {
let spacing = ctx.style_of(ctx.theme()).spacing.clone();
ctx.data_mut(|d| d.insert_temp(key, spacing.clone()));
spacing
});
spacing.interact_size = spacing.interact_size.max(egui::Vec2::splat(TARGET));
spacing.button_padding.x = spacing.button_padding.x.max(14.0);
spacing.item_spacing.y = spacing.item_spacing.y.max(6.0);
spacing.slider_width = spacing.slider_width.max(120.0);
spacing.scroll.bar_width = spacing.scroll.bar_width.max(16.0);
ctx.style_mut_of(ctx.theme(), |style| style.spacing = spacing.clone());
*ui.spacing_mut() = spacing;
} else if let Some(spacing) = saved {
ctx.style_mut_of(ctx.theme(), |style| style.spacing = spacing.clone());
*ui.spacing_mut() = spacing;
ctx.data_mut(|d| d.remove::<egui::style::Spacing>(key));
}
}
pub(super) fn handle_target(state: &brep_render::engine_state::EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
let mut points: Vec<(f64, f64)> = state.transform_handle_points().into_iter().map(|(_, p)| p).collect();
points.extend(state.transform_gizmo_anchor());
points.extend(state.dimension_handle_points().into_iter().map(|(_, p)| p));
points.extend(state.dimension_origin_points().into_iter().map(|(_, p)| p));
if let Ok(serde_json::Value::Object(handles)) = serde_json::from_str(&state.constraint_handle_points_json()) {
points.extend(handles.values().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))));
}
points.into_iter().filter_map(|p| {
let d = (p.0 - x).hypot(p.1 - y);
(d <= f64::from(TARGET) * 0.5).then_some((d, p))
}).min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, p)| p)
}
pub(super) fn sketch_target(state: &brep_render::engine_state::EngineState, x: f64, y: f64, drawing: bool) -> (f64, f64) {
let y = if drawing { (y - CURSOR_OFFSET).clamp(0.0, state.camera.height.max(0.0)) } else { y };
let Some(session) = state.sketch_edit_session() else { return (x, y) };
session.doc.points.iter().filter_map(|p| {
let world = session.plane.to_world(p.x, p.y);
if !state.camera.label_anchor_visible(world) { return None; }
let (sx, sy, _) = state.camera.project(world);
let distance = (sx - x).hypot(sy - y);
(distance <= f64::from(TARGET) * 0.5).then_some((distance, (sx, sy)))
}).min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, p)| p).unwrap_or((x, y))
}
#[derive(Clone, Copy)]
struct Contact {
device: TouchDeviceId,
id: TouchId,
pos: Pos2,
admitted: bool,
}
#[derive(Default)]
pub(super) struct TouchNavigation {
contacts: Vec<Contact>,
captured: bool,
last_frame: Option<(u64, bool)>,
}
#[derive(Default)]
pub(super) struct TouchFrame {
pub suppress_pointer: bool,
pub started: bool,
pub motion: Option<(Pos2, Pos2, f64)>,
}
impl TouchNavigation {
pub fn update_for_frame(
&mut self,
frame: u64,
events: &[Event],
focused: bool,
any_touches: bool,
admit: impl Fn(Pos2) -> bool,
) -> TouchFrame {
if let Some((previous, suppressed)) = self.last_frame {
if previous == frame {
return TouchFrame {
suppress_pointer: suppressed,
..Default::default()
};
}
if previous + 1 != frame {
*self = Self::default();
}
}
let result = self.update(events, focused, any_touches, admit);
self.last_frame = Some((frame, result.suppress_pointer));
result
}
fn pair(&self) -> Option<(Pos2, f32)> {
let [a, b] = self.contacts.as_slice() else {
return None;
};
if !a.admitted || !b.admitted || a.device != b.device {
return None;
}
Some((a.pos + (b.pos - a.pos) * 0.5, a.pos.distance(b.pos)))
}
pub fn update(
&mut self,
events: &[Event],
focused: bool,
any_touches: bool,
admit: impl Fn(Pos2) -> bool,
) -> TouchFrame {
let mut result = TouchFrame {
suppress_pointer: self.captured,
..Default::default()
};
if !focused {
result.started = self.contacts.iter().any(|c| c.admitted);
result.suppress_pointer |= result.started;
*self = Self::default();
return result;
}
let before = self.pair();
let mut membership_changed = false;
for event in events {
let Event::Touch {
device_id,
id,
phase,
pos,
..
} = *event
else {
continue;
};
let index = self
.contacts
.iter()
.position(|c| c.device == device_id && c.id == id);
match phase {
TouchPhase::Start => {
membership_changed = true;
if index.is_none() {
self.contacts.push(Contact {
device: device_id,
id,
pos,
admitted: pos.is_finite() && admit(pos),
});
}
if self.pair().is_some() && !self.captured {
self.captured = true;
result.started = true;
result.suppress_pointer = true;
}
}
TouchPhase::Move => {
if let Some(index) = index {
if pos.is_finite() {
self.contacts[index].pos = pos;
}
}
}
TouchPhase::End | TouchPhase::Cancel => {
membership_changed = true;
if let Some(index) = index {
if phase == TouchPhase::Cancel && self.contacts[index].admitted {
result.started = true;
result.suppress_pointer = true;
self.captured = true;
}
self.contacts.remove(index);
}
}
}
}
if self.captured && !membership_changed {
if let (Some((from, old_distance)), Some((to, distance))) = (before, self.pair()) {
let scale = if old_distance >= 4.0 && distance >= 4.0 {
f64::from(distance) / f64::from(old_distance)
} else {
1.0
};
if from != to || scale != 1.0 {
result.motion = Some((from, to, scale));
}
}
}
if !any_touches || self.contacts.is_empty() {
*self = Self::default();
}
result
}
}