e65e9cc623
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1302 lines
60 KiB
Rust
1302 lines
60 KiB
Rust
use super::*;
|
|
|
|
pub(crate) fn build_window_attributes<A: App>() -> WindowAttributes {
|
|
let (w, h) = A::initial_size();
|
|
let attrs = WindowAttributes::default()
|
|
.with_title(A::title())
|
|
.with_inner_size(LogicalSize::new(w, h));
|
|
// En Linux, `with_name` del trait de Wayland mapea al `app_id` del
|
|
// xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para
|
|
// reconocer ventanas especiales (greeter, launcher…).
|
|
#[cfg(all(target_os = "linux", not(target_os = "android")))]
|
|
{
|
|
if let Some(id) = A::app_id() {
|
|
use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland;
|
|
return attrs.with_name(id, "");
|
|
}
|
|
}
|
|
attrs
|
|
}
|
|
|
|
impl<A: App> ApplicationHandler<UserEvent<A::Msg>> for Runtime<A> {
|
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
|
if self.state.is_some() {
|
|
return;
|
|
}
|
|
let window = event_loop
|
|
.create_window(build_window_attributes::<A>())
|
|
.expect("create window");
|
|
let window = Arc::new(window);
|
|
// IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`).
|
|
// Con IME activo el texto compuesto llega por `WindowEvent::Ime`.
|
|
if A::ime_allowed() {
|
|
window.set_ime_allowed(true);
|
|
}
|
|
let hal = pollster::block_on(Hal::new(None)).expect("hal");
|
|
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
|
|
let renderer = Renderer::new(&hal).expect("renderer");
|
|
let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device);
|
|
let typesetter = llimphi_text::Typesetter::new();
|
|
window.request_redraw();
|
|
self.state = Some(RuntimeState {
|
|
window,
|
|
hal,
|
|
surface,
|
|
renderer,
|
|
scene: vello::Scene::new(),
|
|
overlay_compositor,
|
|
model: Some(A::init(&self.handle)),
|
|
cursor: PhysicalPosition::new(0.0, 0.0),
|
|
modifiers: Modifiers::default(),
|
|
typesetter,
|
|
layout: LayoutTree::new(),
|
|
overlay_layout: LayoutTree::new(),
|
|
last_render: None,
|
|
hovered: None,
|
|
drag: None,
|
|
focused: None,
|
|
last_title: None,
|
|
});
|
|
// Sincroniza el factor de escala inicial (el de la ventana recién
|
|
// creada) ANTES del primer render: así una app que dependa del DPI
|
|
// (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su
|
|
// primera pasada, sin esperar a un ScaleFactorChanged.
|
|
if let Some(state) = self.state.as_mut() {
|
|
let scale = state.window.scale_factor();
|
|
if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent<A::Msg>) {
|
|
match event {
|
|
UserEvent::Quit => event_loop.exit(),
|
|
UserEvent::Msg(msg) => {
|
|
// Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo
|
|
// de fondo) muta el modelo compartido y repinta TODAS las
|
|
// ventanas — así un cambio se refleja tanto en la primaria como
|
|
// en las secundarias (config) sin importar de dónde vino.
|
|
self.dispatch_model(msg);
|
|
}
|
|
UserEvent::OpenWindow { key, title, width, height } => {
|
|
self.open_secondary(event_loop, key, title, width, height);
|
|
}
|
|
UserEvent::CloseWindow { key } => {
|
|
if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) {
|
|
// Drop de la SecondaryState → se destruye la ventana/surface.
|
|
self.secondaries.remove(pos);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn window_event(
|
|
&mut self,
|
|
event_loop: &ActiveEventLoop,
|
|
_id: WindowId,
|
|
event: WindowEvent,
|
|
) {
|
|
// ¿El evento es de una ventana secundaria? Lo atiende su handler
|
|
// dedicado (path aparte: la primaria queda 100% intacta).
|
|
if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) {
|
|
self.handle_secondary_event(idx, event);
|
|
return;
|
|
}
|
|
let Some(state) = self.state.as_mut() else {
|
|
return;
|
|
};
|
|
match event {
|
|
WindowEvent::CloseRequested => event_loop.exit(),
|
|
WindowEvent::Resized(size) => {
|
|
state.surface.resize(size.width, size.height);
|
|
// La app puede reaccionar al nuevo viewport (emitir un
|
|
// evento `resize`, recalcular layout, etc.). El update se
|
|
// corre tras reconfigurar la surface; el cache se invalida
|
|
// para repintar con el tamaño nuevo.
|
|
if let Some(msg) =
|
|
A::on_resize(state.model.as_ref().expect("model"), size.width, size.height)
|
|
{
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
}
|
|
state.window.request_redraw();
|
|
}
|
|
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
|
// El DPI de la ventana cambió (movida a otro monitor, escalado
|
|
// del sistema). winit envía un Resized aparte para el nuevo
|
|
// tamaño físico; aquí sólo propagamos el factor.
|
|
if let Some(msg) =
|
|
A::on_scale_factor(state.model.as_ref().expect("model"), scale_factor)
|
|
{
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
}
|
|
state.window.request_redraw();
|
|
}
|
|
WindowEvent::CursorMoved { position, .. } => {
|
|
let prev_cursor = state.cursor;
|
|
state.cursor = position;
|
|
// Drag activo: dispatchear delta al handler + actualizar
|
|
// tracking del drop target hovereado (solo si hay payload).
|
|
if let Some(drag) = state.drag.as_mut() {
|
|
let dx = (position.x - drag.last_cursor.x) as f32;
|
|
let dy = (position.y - drag.last_cursor.y) as f32;
|
|
drag.last_cursor = position;
|
|
let payload_active = drag.payload.is_some();
|
|
let mut need_redraw = false;
|
|
if dx != 0.0 || dy != 0.0 {
|
|
let msg_opt = match &drag.handler {
|
|
DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy),
|
|
DragHandlerKind::DeltaAt(h, lx0, ly0) => {
|
|
h(DragPhase::Move, dx, dy, *lx0, *ly0)
|
|
}
|
|
};
|
|
if let Some(msg) = msg_opt {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
// Durante drag NO invalidamos el cache —
|
|
// queda válido para el próximo Move.
|
|
need_redraw = true;
|
|
}
|
|
}
|
|
if payload_active {
|
|
if let Some(cache) = state.last_render.as_mut() {
|
|
let new_drop = hit_test_drop(
|
|
&cache.mounted,
|
|
&cache.computed,
|
|
position.x as f32,
|
|
position.y as f32,
|
|
);
|
|
if new_drop != cache.drop_hover_idx {
|
|
cache.drop_hover_idx = new_drop;
|
|
need_redraw = true;
|
|
}
|
|
}
|
|
}
|
|
if need_redraw {
|
|
state.window.request_redraw();
|
|
}
|
|
} else {
|
|
// Sin drag: chequear hover. Si hay overlay, el
|
|
// hover-test va contra él; el árbol principal queda
|
|
// congelado mientras el overlay esté arriba.
|
|
//
|
|
// Además del repintado (para el `hover_fill`), si el
|
|
// nodo recién hovereado declara un `on_pointer_enter`,
|
|
// lo dispatcheamos: es lo que permite, p.ej., cambiar
|
|
// de menú con el mouse o abrir un submenú al pasar por
|
|
// encima. Extraemos el Msg en un scope para soltar el
|
|
// borrow del cache antes de mutar el modelo.
|
|
let mut enter_msg: Option<A::Msg> = None;
|
|
let mut hovered_changed = false;
|
|
let mut new_hovered: Option<usize> = state.hovered;
|
|
if let Some(cache) = state.last_render.as_ref() {
|
|
let (mounted, computed) = match cache.overlay.as_ref() {
|
|
Some(ov) => (&ov.mounted, &ov.computed),
|
|
None => (&cache.mounted, &cache.computed),
|
|
};
|
|
let new_hover = hit_test_hover(
|
|
mounted,
|
|
computed,
|
|
position.x as f32,
|
|
position.y as f32,
|
|
);
|
|
// Comparamos contra el hover PERSISTENTE (state.hovered),
|
|
// no contra el del cache: el render recomputa el del cache
|
|
// al cursor actual cada cuadro, así que en una app que
|
|
// re-renderiza sin parar la transición de hover se perdería
|
|
// (y el hover-switch de menús no andaría). Ver `hovered`.
|
|
if new_hover != state.hovered {
|
|
hovered_changed = true;
|
|
enter_msg = new_hover
|
|
.and_then(|i| mounted.nodes.get(i))
|
|
.and_then(|n| n.on_pointer_enter.clone());
|
|
}
|
|
new_hovered = new_hover;
|
|
}
|
|
state.hovered = new_hovered;
|
|
if hovered_changed {
|
|
state.window.request_redraw();
|
|
}
|
|
if let Some(msg) = enter_msg {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
// El estado cambió → invalidamos el cache para
|
|
// re-render (p.ej. el submenú que se abre).
|
|
state.last_render = None;
|
|
}
|
|
let _ = prev_cursor;
|
|
}
|
|
}
|
|
WindowEvent::ModifiersChanged(mods) => {
|
|
state.modifiers = mods.state().into();
|
|
}
|
|
WindowEvent::Ime(ime) if A::ime_allowed() => {
|
|
use llimphi_hal::winit::event::Ime;
|
|
let ev = match ime {
|
|
Ime::Enabled => ImeEvent::Enabled,
|
|
Ime::Preedit(text, cursor) => ImeEvent::Preedit { text, cursor },
|
|
Ime::Commit(text) => ImeEvent::Commit(text),
|
|
Ime::Disabled => ImeEvent::Disabled,
|
|
};
|
|
if let Some(msg) = A::on_ime(state.model.as_ref().expect("model"), &ev) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::KeyboardInput { event, .. } => {
|
|
// Tab / Shift+Tab mueven el foco entre nodos `focusable`,
|
|
// que administra el runtime. Sólo intercepta si hay
|
|
// enfocables y en Pressed; si no, cae al `on_key` normal
|
|
// (apps que usan Tab para otra cosa lo siguen recibiendo).
|
|
let is_tab = event.state == ElementState::Pressed
|
|
&& matches!(event.logical_key, Key::Named(NamedKey::Tab));
|
|
if is_tab {
|
|
let order = state
|
|
.last_render
|
|
.as_ref()
|
|
.map(|c| focus_order(&c.mounted, &c.computed))
|
|
.unwrap_or_default();
|
|
if !order.is_empty() {
|
|
let next = next_focus(&order, state.focused, state.modifiers.shift);
|
|
state.focused = next;
|
|
if let Some(msg) =
|
|
A::on_focus(state.model.as_ref().expect("model"), next)
|
|
{
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
}
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
return;
|
|
}
|
|
}
|
|
let ev = KeyEvent {
|
|
key: event.logical_key.clone(),
|
|
state: match event.state {
|
|
ElementState::Pressed => KeyState::Pressed,
|
|
ElementState::Released => KeyState::Released,
|
|
},
|
|
text: event.text.as_ref().map(|t| t.to_string()),
|
|
modifiers: state.modifiers,
|
|
repeat: event.repeat,
|
|
};
|
|
if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::DroppedFile(path) => {
|
|
// Un evento por archivo (winit los entrega serializados); si
|
|
// el usuario suelta varios, el bucle re-entra y aplicamos
|
|
// updates en orden.
|
|
if let Some(msg) = A::on_file_drop(state.model.as_ref().expect("model"), path) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::MouseWheel { delta, .. } => {
|
|
// Convención winit: LineDelta es líneas; PixelDelta es
|
|
// píxeles físicos (touchpads). En CSS y aquí, positivo
|
|
// (rueda hacia adelante / dos dedos arriba) = scroll
|
|
// hacia arriba, así que invertimos `y` para que el
|
|
// contenido "siga al dedo" en y positivo. `x` queda
|
|
// como llega.
|
|
let wd = match delta {
|
|
MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y },
|
|
MouseScrollDelta::PixelDelta(p) => WheelDelta {
|
|
x: (p.x as f32) / 20.0,
|
|
y: -(p.y as f32) / 20.0,
|
|
},
|
|
};
|
|
let cursor = (state.cursor.x as f32, state.cursor.y as f32);
|
|
// Primero: ¿hay un nodo con `on_scroll` bajo el cursor? Si
|
|
// consume el evento (`Some`), no cae al `on_wheel` global.
|
|
// El overlay tiene prioridad, igual que con clicks. Se
|
|
// extrae el handler en un scope para soltar el borrow del
|
|
// cache antes de mutar el modelo.
|
|
let scroll_handler: Option<ScrollFn<A::Msg>> =
|
|
if let Some(cache) = state.last_render.as_ref() {
|
|
let (m, c) = match cache.overlay.as_ref() {
|
|
Some(ov) => (&ov.mounted, &ov.computed),
|
|
None => (&cache.mounted, &cache.computed),
|
|
};
|
|
hit_test_scroll(m, c, cursor.0, cursor.1)
|
|
.and_then(|i| m.nodes[i].on_scroll.clone())
|
|
} else {
|
|
None
|
|
};
|
|
let msg = match scroll_handler {
|
|
Some(h) => h(wd.x, wd.y),
|
|
None => A::on_wheel(
|
|
state.model.as_ref().expect("model"),
|
|
wd,
|
|
cursor,
|
|
state.modifiers,
|
|
),
|
|
};
|
|
if let Some(msg) = msg {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::MouseInput {
|
|
state: ElementState::Pressed,
|
|
button: MouseButton::Left,
|
|
..
|
|
} => {
|
|
// Hit-test contra el cache del último redraw (siempre
|
|
// representa lo visible). Fallback raro: cache vacío.
|
|
let cursor = state.cursor;
|
|
// Click-to-focus: si el click cae sobre un nodo enfocable,
|
|
// el runtime le da el foco ANTES de procesar la acción de
|
|
// click. Extraemos el id en un scope (suelta el borrow del
|
|
// cache) y recién después mutamos el foco/modelo.
|
|
let focus_hit = state
|
|
.last_render
|
|
.as_ref()
|
|
.and_then(|cache| {
|
|
let (m, c) = match cache.overlay.as_ref() {
|
|
Some(ov) => (&ov.mounted, &ov.computed),
|
|
None => (&cache.mounted, &cache.computed),
|
|
};
|
|
hit_test_focusable(m, c, cursor.x as f32, cursor.y as f32)
|
|
});
|
|
if focus_hit.is_some() && focus_hit != state.focused {
|
|
state.focused = focus_hit;
|
|
if let Some(msg) =
|
|
A::on_focus(state.model.as_ref().expect("model"), focus_hit)
|
|
{
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
}
|
|
state.last_render = None;
|
|
}
|
|
// Tupla: (drag_fn, drag_at_fn, payload, on_click_msg,
|
|
// on_click_at_handler, rect: (x, y, w, h))
|
|
type HitInfo<M> = (
|
|
Option<DragFn<M>>,
|
|
Option<DragAtFn<M>>,
|
|
Option<u64>,
|
|
Option<M>,
|
|
Option<ClickAtFn<M>>,
|
|
Option<(f32, f32, f32, f32)>,
|
|
);
|
|
let lookup_hit = |m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<HitInfo<A::Msg>> {
|
|
hit_test_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| {
|
|
let node = &m.nodes[i];
|
|
let rect = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h));
|
|
(
|
|
node.drag.clone(),
|
|
node.drag_at.clone(),
|
|
node.drag_payload,
|
|
node.on_click.clone(),
|
|
node.on_click_at.clone(),
|
|
rect,
|
|
)
|
|
})
|
|
};
|
|
// Con overlay activo, los clicks van EXCLUSIVAMENTE a él.
|
|
// Si el cursor cae sobre un nodo del overlay sin handler,
|
|
// el click se descarta — la convención de "scrim que
|
|
// dismissa" pide que la app meta su propio fondo
|
|
// clicable con `on_click = DismissOverlay`.
|
|
let idx_and_action: Option<HitInfo<A::Msg>> = if let Some(cache) =
|
|
state.last_render.as_ref()
|
|
{
|
|
if let Some(ov) = cache.overlay.as_ref() {
|
|
lookup_hit(&ov.mounted, &ov.computed)
|
|
} else {
|
|
lookup_hit(&cache.mounted, &cache.computed)
|
|
}
|
|
} else {
|
|
let model_ref = state.model.as_ref().expect("model");
|
|
let view = A::view(model_ref);
|
|
let overlay_view = A::view_overlay(model_ref);
|
|
let mut layout = LayoutTree::new();
|
|
let mounted: Mounted<A::Msg> = mount(&mut layout, view);
|
|
let (w, h) = state.surface.size();
|
|
let ts = &mut state.typesetter;
|
|
let computed = {
|
|
let tmap = &mounted.text_measures;
|
|
layout
|
|
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
|
|
match tmap.get(&nid) {
|
|
Some(tm) => measure_text_node(ts, tm, known, avail),
|
|
None => llimphi_layout::taffy::Size::ZERO,
|
|
}
|
|
})
|
|
.expect("layout")
|
|
};
|
|
if let Some(ov) = overlay_view {
|
|
let mut olay = LayoutTree::new();
|
|
let omounted: Mounted<A::Msg> = mount(&mut olay, ov);
|
|
let ocomp = {
|
|
let tmap = &omounted.text_measures;
|
|
olay
|
|
.compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
|
|
match tmap.get(&nid) {
|
|
Some(tm) => measure_text_node(ts, tm, known, avail),
|
|
None => llimphi_layout::taffy::Size::ZERO,
|
|
}
|
|
})
|
|
.expect("layout overlay")
|
|
};
|
|
lookup_hit(&omounted, &ocomp)
|
|
} else {
|
|
lookup_hit(&mounted, &computed)
|
|
}
|
|
};
|
|
// drag_at + on_click_at COEXISTEN: el press dispara
|
|
// on_click_at (si está) y arranca un drag rastreado con la
|
|
// posición inicial. Diseño pensado para canvas elements
|
|
// que necesitan select-on-press + move-on-drag.
|
|
//
|
|
// En cambio, `drag` simple (sin _at) mantiene la semántica
|
|
// antigua: gana exclusivo sobre on_click.
|
|
if let Some((_, Some(handler_at), payload, _, click_at, Some((ox, oy, rw, rh)))) =
|
|
&idx_and_action
|
|
{
|
|
let lx0 = cursor.x as f32 - ox;
|
|
let ly0 = cursor.y as f32 - oy;
|
|
// Disparar on_click_at en el press (si también está).
|
|
if let Some(click_at_h) = click_at {
|
|
if let Some(msg) = click_at_h(lx0, ly0, *rw, *rh) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
}
|
|
}
|
|
state.drag = Some(DragState {
|
|
handler: DragHandlerKind::DeltaAt(handler_at.clone(), lx0, ly0),
|
|
last_cursor: cursor,
|
|
payload: *payload,
|
|
});
|
|
state.window.request_redraw();
|
|
} else if let Some((Some(handler), _, payload, _, _, _)) = &idx_and_action {
|
|
state.drag = Some(DragState {
|
|
handler: DragHandlerKind::Delta(handler.clone()),
|
|
last_cursor: cursor,
|
|
payload: *payload,
|
|
});
|
|
// Si hay payload, repintar para que el drop target
|
|
// bajo cursor (si lo hay) se ilumine de entrada.
|
|
if payload.is_some() {
|
|
if let Some(cache) = state.last_render.as_mut() {
|
|
let new_drop = hit_test_drop(
|
|
&cache.mounted,
|
|
&cache.computed,
|
|
cursor.x as f32,
|
|
cursor.y as f32,
|
|
);
|
|
if new_drop != cache.drop_hover_idx {
|
|
cache.drop_hover_idx = new_drop;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
} else if let Some((_, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) =
|
|
&idx_and_action
|
|
{
|
|
// on_click_at gana sobre on_click si ambos existen.
|
|
let lx = cursor.x as f32 - ox;
|
|
let ly = cursor.y as f32 - oy;
|
|
if let Some(msg) = handler(lx, ly, *rw, *rh) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
} else if let Some((_, _, _, Some(msg), _, _)) = idx_and_action {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::MouseInput {
|
|
state: ElementState::Pressed,
|
|
button: MouseButton::Middle,
|
|
..
|
|
} => {
|
|
// Middle-click: dispatcha `on_middle_click` del nodo
|
|
// bajo cursor si lo declaró. La capa overlay tiene
|
|
// prioridad (mismo razonamiento que el left/right click).
|
|
let cursor = state.cursor;
|
|
let lookup =
|
|
|m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<A::Msg> {
|
|
hit_test_middle_click(m, c, cursor.x as f32, cursor.y as f32)
|
|
.and_then(|i| m.nodes[i].on_middle_click.clone())
|
|
};
|
|
let msg = if let Some(cache) = state.last_render.as_ref() {
|
|
if let Some(ov) = cache.overlay.as_ref() {
|
|
lookup(&ov.mounted, &ov.computed)
|
|
} else {
|
|
lookup(&cache.mounted, &cache.computed)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(msg) = msg {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::MouseInput {
|
|
state: ElementState::Pressed,
|
|
button: MouseButton::Right,
|
|
..
|
|
} => {
|
|
// Right-click: dispatcheamos `on_right_click` o
|
|
// `on_right_click_at` del nodo bajo cursor. La capa
|
|
// overlay tiene prioridad (mismo razonamiento que el
|
|
// left-click). Nodos sin handler de right-click no
|
|
// reaccionan — no "filtramos" al left.
|
|
let cursor = state.cursor;
|
|
let lookup =
|
|
|m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<(Option<A::Msg>, Option<ClickAtFn<A::Msg>>, (f32, f32, f32, f32))> {
|
|
hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| {
|
|
let node = &m.nodes[i];
|
|
let rect = c
|
|
.get(node.id)
|
|
.map(|r| (r.x, r.y, r.w, r.h))
|
|
.unwrap_or((0.0, 0.0, 0.0, 0.0));
|
|
(
|
|
node.on_right_click.clone(),
|
|
node.on_right_click_at.clone(),
|
|
rect,
|
|
)
|
|
})
|
|
};
|
|
let hit = if let Some(cache) = state.last_render.as_ref() {
|
|
if let Some(ov) = cache.overlay.as_ref() {
|
|
lookup(&ov.mounted, &ov.computed)
|
|
} else {
|
|
lookup(&cache.mounted, &cache.computed)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
if let Some((msg_opt, at_opt, (ox, oy, rw, rh))) = hit {
|
|
let msg = if let Some(handler) = at_opt {
|
|
handler(
|
|
cursor.x as f32 - ox,
|
|
cursor.y as f32 - oy,
|
|
rw,
|
|
rh,
|
|
)
|
|
} else {
|
|
msg_opt
|
|
};
|
|
if let Some(msg) = msg {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
WindowEvent::MouseInput {
|
|
state: ElementState::Released,
|
|
button: MouseButton::Left,
|
|
..
|
|
} => {
|
|
if let Some(drag) = state.drag.take() {
|
|
let cursor = state.cursor;
|
|
// 1. Drop: si hay payload + drop target bajo cursor,
|
|
// invocamos su handler. El Msg resultante se aplica
|
|
// ANTES del End del drag — la convención es "drop
|
|
// primero, cleanup del drag después".
|
|
if let Some(payload) = drag.payload {
|
|
if let Some(cache) = state.last_render.as_ref() {
|
|
if let Some(idx) = hit_test_drop(
|
|
&cache.mounted,
|
|
&cache.computed,
|
|
cursor.x as f32,
|
|
cursor.y as f32,
|
|
) {
|
|
if let Some(drop_h) =
|
|
cache.mounted.nodes[idx].on_drop.clone()
|
|
{
|
|
if let Some(msg) = (drop_h)(payload) {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 2. Cierre del drag.
|
|
let end_msg = match &drag.handler {
|
|
DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
|
|
DragHandlerKind::DeltaAt(h, lx0, ly0) => {
|
|
h(DragPhase::End, 0.0, 0.0, *lx0, *ly0)
|
|
}
|
|
};
|
|
if let Some(msg) = end_msg {
|
|
let model = state.model.take().expect("model");
|
|
state.model = Some(A::update(model, msg, &self.handle));
|
|
}
|
|
// Cache invalidado siempre — hover/drop pueden cambiar
|
|
// y el modelo posiblemente mutó.
|
|
state.last_render = None;
|
|
state.window.request_redraw();
|
|
}
|
|
}
|
|
WindowEvent::RedrawRequested => {
|
|
// Título dinámico (App::window_title): si cambió respecto del
|
|
// último aplicado, se lo pasamos a winit. Barato: una
|
|
// comparación de String por frame, set_title sólo en el cambio.
|
|
if let Some(t) = A::window_title(state.model.as_ref().expect("model")) {
|
|
if state.last_title.as_deref() != Some(t.as_str()) {
|
|
state.window.set_title(&t);
|
|
state.last_title = Some(t);
|
|
}
|
|
}
|
|
// Posicioná la ventana de candidatos del IME junto al caret
|
|
// (sólo con IME activo y si la app reporta el área).
|
|
if A::ime_allowed() {
|
|
if let Some((x, y, w, h)) =
|
|
A::ime_cursor_area(state.model.as_ref().expect("model"))
|
|
{
|
|
state.window.set_ime_cursor_area(
|
|
PhysicalPosition::new(x as f64, y as f64),
|
|
llimphi_hal::winit::dpi::PhysicalSize::new(
|
|
w.max(1.0) as u32,
|
|
h.max(1.0) as u32,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
let frame = match state.surface.acquire() {
|
|
Ok(f) => f,
|
|
Err(_) => {
|
|
let (w, h) = state.surface.size();
|
|
state.surface.resize(w, h);
|
|
state.window.request_redraw();
|
|
return;
|
|
}
|
|
};
|
|
let (w, h) = frame.size();
|
|
let model_ref = state.model.as_ref().expect("model");
|
|
let view = A::view(model_ref);
|
|
let overlay_view = A::view_overlay(model_ref);
|
|
// Reusamos los árboles de layout del runtime: `clear()` +
|
|
// `mount` evita re-allocar el slotmap de taffy por frame.
|
|
state.layout.clear();
|
|
let mounted: Mounted<A::Msg> = mount(&mut state.layout, view);
|
|
let computed = {
|
|
let ts = &mut state.typesetter;
|
|
let tmap = &mounted.text_measures;
|
|
state
|
|
.layout
|
|
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
|
|
match tmap.get(&nid) {
|
|
Some(tm) => measure_text_node(ts, tm, known, avail),
|
|
None => llimphi_layout::taffy::Size::ZERO,
|
|
}
|
|
})
|
|
.expect("layout")
|
|
};
|
|
// Mount + layout del overlay en un árbol aparte. Lo
|
|
// computamos con el mismo tamaño de viewport para que
|
|
// un scrim a percent(1.0) cubra toda la pantalla.
|
|
let overlay_built = if let Some(v) = overlay_view {
|
|
state.overlay_layout.clear();
|
|
let omounted: Mounted<A::Msg> = mount(&mut state.overlay_layout, v);
|
|
let ocomputed = {
|
|
let ts = &mut state.typesetter;
|
|
let tmap = &omounted.text_measures;
|
|
state
|
|
.overlay_layout
|
|
.compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
|
|
match tmap.get(&nid) {
|
|
Some(tm) => measure_text_node(ts, tm, known, avail),
|
|
None => llimphi_layout::taffy::Size::ZERO,
|
|
}
|
|
})
|
|
.expect("layout overlay")
|
|
};
|
|
let ohover = hit_test_hover(
|
|
&omounted,
|
|
&ocomputed,
|
|
state.cursor.x as f32,
|
|
state.cursor.y as f32,
|
|
);
|
|
Some(OverlayCache {
|
|
mounted: omounted,
|
|
computed: ocomputed,
|
|
hover_idx: ohover,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
// Hover en el main solo si NO hay overlay — durante un
|
|
// menú abierto, el fondo no debe reaccionar al ratón.
|
|
let hover_idx = if overlay_built.is_some() {
|
|
None
|
|
} else {
|
|
hit_test_hover(
|
|
&mounted,
|
|
&computed,
|
|
state.cursor.x as f32,
|
|
state.cursor.y as f32,
|
|
)
|
|
};
|
|
// Drop hover sólo si hay drag activo con payload (un
|
|
// drag bloquea el overlay; rara combinación pero la
|
|
// resolvemos a favor del drag).
|
|
let drop_hover_idx = state
|
|
.drag
|
|
.as_ref()
|
|
.and_then(|d| d.payload.map(|_| ()))
|
|
.and_then(|_| {
|
|
hit_test_drop(
|
|
&mounted,
|
|
&computed,
|
|
state.cursor.x as f32,
|
|
state.cursor.y as f32,
|
|
)
|
|
});
|
|
// Z-order del overlay sobre contenido `gpu_paint`: si el
|
|
// árbol principal tiene painters gpu (p. ej. el video de
|
|
// media) Y hay un overlay activo, el overlay NO va en la
|
|
// escena principal (quedaría debajo del blit gpu). Se
|
|
// rasteriza aparte sobre fondo transparente y se compone con
|
|
// alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino
|
|
// de siempre (overlay en la escena principal) — coste cero.
|
|
let composite_overlay =
|
|
overlay_built.is_some() && has_gpu_painter(&mounted);
|
|
|
|
state.scene.reset();
|
|
paint(
|
|
&mut state.scene,
|
|
&mounted,
|
|
&computed,
|
|
&mut state.typesetter,
|
|
hover_idx,
|
|
drop_hover_idx,
|
|
);
|
|
if !composite_overlay {
|
|
if let Some(ov) = overlay_built.as_ref() {
|
|
paint(
|
|
&mut state.scene,
|
|
&ov.mounted,
|
|
&ov.computed,
|
|
&mut state.typesetter,
|
|
ov.hover_idx,
|
|
None,
|
|
);
|
|
}
|
|
}
|
|
if let Err(e) = state.renderer.render(
|
|
&state.hal,
|
|
&state.scene,
|
|
&frame,
|
|
palette::css::BLACK,
|
|
) {
|
|
eprintln!("render error: {e}");
|
|
}
|
|
let (vw, vh) = frame.size();
|
|
// Capa de overlay aparte (camino composite): vello la
|
|
// rasteriza con fondo transparente en `frame.overlay_view()`.
|
|
// Se renderiza ANTES del pase gpu para que el blit del
|
|
// compositor (en `gpu_encoder`) la lea ya escrita.
|
|
if composite_overlay {
|
|
if let Some(ov) = overlay_built.as_ref() {
|
|
state.scene.reset();
|
|
paint(
|
|
&mut state.scene,
|
|
&ov.mounted,
|
|
&ov.computed,
|
|
&mut state.typesetter,
|
|
ov.hover_idx,
|
|
None,
|
|
);
|
|
if let Err(e) = state.renderer.render_to_view(
|
|
&state.hal,
|
|
&state.scene,
|
|
frame.overlay_view(),
|
|
vw,
|
|
vh,
|
|
palette::css::TRANSPARENT,
|
|
) {
|
|
eprintln!("render overlay error: {e}");
|
|
}
|
|
}
|
|
}
|
|
// Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"):
|
|
// si algún View del main o del overlay registró un
|
|
// `gpu_painter`, ejecutamos todos sus callbacks contra un
|
|
// único `CommandEncoder`, encima de lo que vello acaba de
|
|
// pintar sobre la intermediate. Submitimos antes del
|
|
// present para que el blit al swapchain incluya las
|
|
// primitivas GPU. Si nadie usó el hook, no se crea ni
|
|
// submitea nada — coste cero.
|
|
let mut gpu_encoder = state.hal.device.create_command_encoder(
|
|
&llimphi_hal::wgpu::CommandEncoderDescriptor {
|
|
label: Some("llimphi-ui-gpu-paint"),
|
|
},
|
|
);
|
|
let viewport = frame.size();
|
|
let mut any_gpu = paint_gpu(
|
|
&mounted,
|
|
&computed,
|
|
&state.hal.device,
|
|
&state.hal.queue,
|
|
&mut gpu_encoder,
|
|
frame.view(),
|
|
viewport,
|
|
);
|
|
if let Some(ov) = overlay_built.as_ref() {
|
|
// En el camino composite, los painters gpu del overlay van
|
|
// sobre SU textura; si no, sobre la intermedia.
|
|
let target = if composite_overlay {
|
|
frame.overlay_view()
|
|
} else {
|
|
frame.view()
|
|
};
|
|
any_gpu |= paint_gpu(
|
|
&ov.mounted,
|
|
&ov.computed,
|
|
&state.hal.device,
|
|
&state.hal.queue,
|
|
&mut gpu_encoder,
|
|
target,
|
|
viewport,
|
|
);
|
|
}
|
|
// Composición alpha del overlay SOBRE la intermedia (que ya
|
|
// tiene UI + video). Último pase del encoder → corre después
|
|
// del blit del video. Garantiza menús por encima del video.
|
|
if composite_overlay {
|
|
state.overlay_compositor.composite(
|
|
&state.hal.device,
|
|
&mut gpu_encoder,
|
|
frame.view(),
|
|
frame.overlay_view(),
|
|
);
|
|
any_gpu = true;
|
|
}
|
|
if any_gpu {
|
|
state
|
|
.hal
|
|
.queue
|
|
.submit(std::iter::once(gpu_encoder.finish()));
|
|
}
|
|
state.surface.present(frame, &state.hal);
|
|
state.last_render = Some(RenderCache {
|
|
mounted,
|
|
computed,
|
|
hover_idx,
|
|
drop_hover_idx,
|
|
overlay: overlay_built,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Ventanas secundarias (multiventana, opt-in) ──────────────────────────────
|
|
// Path APARTE del de la primaria: comparten modelo (vive en `self.state`) y
|
|
// `Hal`/`Renderer`, pero cada secundaria lleva su surface + caches. Sin
|
|
// overlay ni foco (la config no los necesita); se puede ampliar luego.
|
|
impl<A: App> Runtime<A> {
|
|
/// Aplica un Msg al modelo (que vive en la primaria) e invalida + repinta
|
|
/// TODAS las ventanas. Es el camino de cualquier evento de una secundaria,
|
|
/// así un cambio hecho en la config se refleja al toque en el reproductor
|
|
/// (y viceversa, vía los ticks que pasan por `user_event`).
|
|
fn dispatch_model(&mut self, msg: A::Msg) {
|
|
if let Some(prim) = self.state.as_mut() {
|
|
let model = prim.model.take().expect("model");
|
|
prim.model = Some(A::update(model, msg, &self.handle));
|
|
prim.last_render = None;
|
|
prim.window.request_redraw();
|
|
}
|
|
// OJO: NO repintamos las secundarias acá. `dispatch_model` corre en
|
|
// cada Msg (incluido el tick ~33 fps), y repintar una secundaria por
|
|
// tick serializaba dos `acquire()` de swapchain en Wayland FIFO →
|
|
// ralentización y cuelgue. Cada secundaria se repinta sola al
|
|
// interactuar con ella (`handle_secondary_event` llama
|
|
// `render_secondary` tras un cambio) y en su `RedrawRequested` del
|
|
// compositor (expose/resize). El modelo igual quedó actualizado, así
|
|
// que el próximo repintado de la secundaria refleja el cambio.
|
|
}
|
|
|
|
/// Despacha un Msg y repinta la secundaria `idx` en el acto (si sigue
|
|
/// viva). El camino de los eventos de una secundaria: como su
|
|
/// `request_redraw` no dispara `RedrawRequested` en algunos compositores,
|
|
/// la pintamos directo tras el cambio.
|
|
fn dispatch_and_render_secondary(&mut self, idx: usize, msg: A::Msg) {
|
|
self.dispatch_model(msg);
|
|
if idx < self.secondaries.len() {
|
|
self.render_secondary(idx);
|
|
}
|
|
}
|
|
|
|
/// Crea una ventana OS secundaria (o enfoca la existente con esa key). Toma
|
|
/// el `Hal` de la primaria — no levanta un segundo device GPU.
|
|
fn open_secondary(
|
|
&mut self,
|
|
event_loop: &ActiveEventLoop,
|
|
key: u64,
|
|
title: String,
|
|
width: u32,
|
|
height: u32,
|
|
) {
|
|
if let Some(sec) = self.secondaries.iter().find(|s| s.key == key) {
|
|
sec.window.focus_window();
|
|
return;
|
|
}
|
|
let Some(prim) = self.state.as_ref() else {
|
|
return; // no hay primaria todavía (no debería pasar)
|
|
};
|
|
let attrs = WindowAttributes::default()
|
|
.with_title(title)
|
|
.with_inner_size(LogicalSize::new(width, height));
|
|
let window = match event_loop.create_window(attrs) {
|
|
Ok(w) => Arc::new(w),
|
|
Err(e) => {
|
|
eprintln!("open_window: no pude crear la ventana: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let surface = match WinitSurface::new(&prim.hal, window.clone()) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("open_window: no pude crear la surface: {e}");
|
|
return;
|
|
}
|
|
};
|
|
window.request_redraw();
|
|
self.secondaries.push(SecondaryState {
|
|
key,
|
|
window,
|
|
surface,
|
|
scene: vello::Scene::new(),
|
|
typesetter: llimphi_text::Typesetter::new(),
|
|
layout: LayoutTree::new(),
|
|
cursor: PhysicalPosition::new(0.0, 0.0),
|
|
modifiers: Modifiers::default(),
|
|
last_render: None,
|
|
hovered: None,
|
|
drag: None,
|
|
last_title: None,
|
|
});
|
|
}
|
|
|
|
/// Pinta la ventana secundaria `idx` con `A::secondary_view`. Reusa el
|
|
/// `Hal`/`Renderer` de la primaria; camino simple (sin overlay ni
|
|
/// composite gpu de menús), pero soporta `gpu_paint` por si el contenido
|
|
/// lo usa.
|
|
fn render_secondary(&mut self, idx: usize) {
|
|
let key = self.secondaries[idx].key;
|
|
let Some(prim) = self.state.as_mut() else {
|
|
return;
|
|
};
|
|
// Título dinámico de la secundaria.
|
|
if let Some(t) = A::secondary_title(prim.model.as_ref().expect("model"), key) {
|
|
let sec = &mut self.secondaries[idx];
|
|
if sec.last_title.as_deref() != Some(t.as_str()) {
|
|
sec.window.set_title(&t);
|
|
sec.last_title = Some(t);
|
|
}
|
|
}
|
|
let view = A::secondary_view(prim.model.as_ref().expect("model"), key)
|
|
.unwrap_or_else(|| View::new(Default::default()));
|
|
let hal = &prim.hal;
|
|
let renderer = &mut prim.renderer;
|
|
let sec = &mut self.secondaries[idx];
|
|
|
|
let frame = match sec.surface.acquire() {
|
|
Ok(f) => f,
|
|
Err(_) => {
|
|
let (w, h) = sec.surface.size();
|
|
sec.surface.resize(w, h);
|
|
sec.window.request_redraw();
|
|
return;
|
|
}
|
|
};
|
|
let (w, h) = frame.size();
|
|
sec.layout.clear();
|
|
let mounted: Mounted<A::Msg> = mount(&mut sec.layout, view);
|
|
let computed = {
|
|
let ts = &mut sec.typesetter;
|
|
let tmap = &mounted.text_measures;
|
|
sec.layout
|
|
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
|
|
match tmap.get(&nid) {
|
|
Some(tm) => measure_text_node(ts, tm, known, avail),
|
|
None => llimphi_layout::taffy::Size::ZERO,
|
|
}
|
|
})
|
|
.expect("layout secundario")
|
|
};
|
|
let hover_idx = hit_test_hover(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32);
|
|
let drop_hover_idx = sec
|
|
.drag
|
|
.as_ref()
|
|
.and_then(|d| d.payload)
|
|
.and_then(|_| hit_test_drop(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32));
|
|
sec.scene.reset();
|
|
paint(
|
|
&mut sec.scene,
|
|
&mounted,
|
|
&computed,
|
|
&mut sec.typesetter,
|
|
hover_idx,
|
|
drop_hover_idx,
|
|
);
|
|
if let Err(e) = renderer.render(hal, &sec.scene, &frame, palette::css::BLACK) {
|
|
eprintln!("render secundario error: {e}");
|
|
}
|
|
// gpu_paint del contenido de la secundaria (si lo hubiera).
|
|
let mut enc = hal
|
|
.device
|
|
.create_command_encoder(&llimphi_hal::wgpu::CommandEncoderDescriptor {
|
|
label: Some("llimphi-ui-sec-gpu"),
|
|
});
|
|
let viewport = frame.size();
|
|
let any = paint_gpu(
|
|
&mounted,
|
|
&computed,
|
|
&hal.device,
|
|
&hal.queue,
|
|
&mut enc,
|
|
frame.view(),
|
|
viewport,
|
|
);
|
|
if any {
|
|
hal.queue.submit(std::iter::once(enc.finish()));
|
|
}
|
|
sec.surface.present(frame, hal);
|
|
let _ = (hover_idx, drop_hover_idx); // se usaron al pintar; no se cachean
|
|
sec.last_render = Some(SecRenderCache { mounted, computed });
|
|
}
|
|
|
|
/// Atiende un evento de la ventana secundaria `idx`. Subconjunto de lo que
|
|
/// hace la primaria (sin overlay/foco/IME): render, resize, cierre, hover,
|
|
/// click, drag, teclado y rueda — suficiente para un panel de config.
|
|
fn handle_secondary_event(&mut self, idx: usize, event: WindowEvent) {
|
|
match event {
|
|
WindowEvent::CloseRequested => {
|
|
let key = self.secondaries[idx].key;
|
|
let msg = self
|
|
.state
|
|
.as_ref()
|
|
.and_then(|p| A::on_secondary_close(p.model.as_ref().expect("model"), key));
|
|
self.secondaries.remove(idx);
|
|
if let Some(msg) = msg {
|
|
self.dispatch_model(msg);
|
|
}
|
|
}
|
|
WindowEvent::Resized(size) => {
|
|
self.secondaries[idx].surface.resize(size.width, size.height);
|
|
self.render_secondary(idx);
|
|
}
|
|
WindowEvent::ScaleFactorChanged { .. } => {
|
|
self.render_secondary(idx);
|
|
}
|
|
WindowEvent::RedrawRequested => {
|
|
self.render_secondary(idx);
|
|
}
|
|
WindowEvent::ModifiersChanged(mods) => {
|
|
self.secondaries[idx].modifiers = mods.state().into();
|
|
}
|
|
WindowEvent::CursorMoved { position, .. } => {
|
|
let mut drag_msg: Option<A::Msg> = None;
|
|
let mut redraw = false;
|
|
{
|
|
let sec = &mut self.secondaries[idx];
|
|
sec.cursor = position;
|
|
if let Some(drag) = sec.drag.as_mut() {
|
|
let dx = (position.x - drag.last_cursor.x) as f32;
|
|
let dy = (position.y - drag.last_cursor.y) as f32;
|
|
drag.last_cursor = position;
|
|
if dx != 0.0 || dy != 0.0 {
|
|
drag_msg = match &drag.handler {
|
|
DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy),
|
|
DragHandlerKind::DeltaAt(h, lx0, ly0) => {
|
|
h(DragPhase::Move, dx, dy, *lx0, *ly0)
|
|
}
|
|
};
|
|
}
|
|
redraw = true;
|
|
} else {
|
|
let new_hover = sec.last_render.as_ref().and_then(|c| {
|
|
hit_test_hover(&c.mounted, &c.computed, position.x as f32, position.y as f32)
|
|
});
|
|
if new_hover != sec.hovered {
|
|
sec.hovered = new_hover;
|
|
redraw = true;
|
|
}
|
|
}
|
|
}
|
|
if let Some(msg) = drag_msg {
|
|
self.dispatch_and_render_secondary(idx, msg);
|
|
} else if redraw {
|
|
self.render_secondary(idx);
|
|
}
|
|
}
|
|
WindowEvent::MouseInput {
|
|
state: ElementState::Pressed,
|
|
button: MouseButton::Left,
|
|
..
|
|
} => {
|
|
type SecHit<M> = (
|
|
Option<DragFn<M>>,
|
|
Option<DragAtFn<M>>,
|
|
Option<u64>,
|
|
Option<M>,
|
|
Option<ClickAtFn<M>>,
|
|
Option<(f32, f32, f32, f32)>,
|
|
);
|
|
let cursor = self.secondaries[idx].cursor;
|
|
let hit: Option<SecHit<A::Msg>> = {
|
|
let sec = &self.secondaries[idx];
|
|
sec.last_render.as_ref().and_then(|c| {
|
|
hit_test_click(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32).map(
|
|
|i| {
|
|
let node = &c.mounted.nodes[i];
|
|
let rect = c.computed.get(node.id).map(|r| (r.x, r.y, r.w, r.h));
|
|
(
|
|
node.drag.clone(),
|
|
node.drag_at.clone(),
|
|
node.drag_payload,
|
|
node.on_click.clone(),
|
|
node.on_click_at.clone(),
|
|
rect,
|
|
)
|
|
},
|
|
)
|
|
})
|
|
};
|
|
// Misma prioridad que la primaria: drag_at + on_click_at, luego
|
|
// drag simple, luego on_click_at, luego on_click.
|
|
match hit {
|
|
Some((_, Some(handler_at), payload, _, click_at, Some((ox, oy, rw, rh)))) => {
|
|
let lx0 = cursor.x as f32 - ox;
|
|
let ly0 = cursor.y as f32 - oy;
|
|
if let Some(h) = click_at {
|
|
if let Some(msg) = h(lx0, ly0, rw, rh) {
|
|
self.dispatch_model(msg);
|
|
}
|
|
}
|
|
self.secondaries[idx].drag = Some(DragState {
|
|
handler: DragHandlerKind::DeltaAt(handler_at, lx0, ly0),
|
|
last_cursor: cursor,
|
|
payload,
|
|
});
|
|
self.render_secondary(idx);
|
|
}
|
|
Some((Some(handler), _, payload, _, _, _)) => {
|
|
self.secondaries[idx].drag = Some(DragState {
|
|
handler: DragHandlerKind::Delta(handler),
|
|
last_cursor: cursor,
|
|
payload,
|
|
});
|
|
self.render_secondary(idx);
|
|
}
|
|
Some((_, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) => {
|
|
let lx = cursor.x as f32 - ox;
|
|
let ly = cursor.y as f32 - oy;
|
|
if let Some(msg) = handler(lx, ly, rw, rh) {
|
|
self.dispatch_and_render_secondary(idx, msg);
|
|
}
|
|
}
|
|
Some((_, _, _, Some(msg), _, _)) => {
|
|
self.dispatch_and_render_secondary(idx, msg);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
WindowEvent::MouseInput {
|
|
state: ElementState::Released,
|
|
button: MouseButton::Left,
|
|
..
|
|
} => {
|
|
let cursor = self.secondaries[idx].cursor;
|
|
let drag = self.secondaries[idx].drag.take();
|
|
if let Some(drag) = drag {
|
|
// Drop primero (si hay payload + target), luego End.
|
|
if let Some(payload) = drag.payload {
|
|
let drop_h = self.secondaries[idx].last_render.as_ref().and_then(|c| {
|
|
hit_test_drop(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32)
|
|
.and_then(|i| c.mounted.nodes[i].on_drop.clone())
|
|
});
|
|
if let Some(h) = drop_h {
|
|
if let Some(msg) = h(payload) {
|
|
self.dispatch_model(msg);
|
|
}
|
|
}
|
|
}
|
|
let end_msg = match &drag.handler {
|
|
DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
|
|
DragHandlerKind::DeltaAt(h, lx0, ly0) => h(DragPhase::End, 0.0, 0.0, *lx0, *ly0),
|
|
};
|
|
if let Some(msg) = end_msg {
|
|
self.dispatch_model(msg);
|
|
}
|
|
self.render_secondary(idx);
|
|
}
|
|
}
|
|
WindowEvent::MouseWheel { delta, .. } => {
|
|
let wd = match delta {
|
|
MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y },
|
|
MouseScrollDelta::PixelDelta(p) => WheelDelta {
|
|
x: (p.x as f32) / 20.0,
|
|
y: -(p.y as f32) / 20.0,
|
|
},
|
|
};
|
|
let cursor = self.secondaries[idx].cursor;
|
|
let handler = {
|
|
let sec = &self.secondaries[idx];
|
|
sec.last_render.as_ref().and_then(|c| {
|
|
hit_test_scroll(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32)
|
|
.and_then(|i| c.mounted.nodes[i].on_scroll.clone())
|
|
})
|
|
};
|
|
if let Some(msg) = handler.and_then(|h| h(wd.x, wd.y)) {
|
|
self.dispatch_and_render_secondary(idx, msg);
|
|
}
|
|
}
|
|
WindowEvent::KeyboardInput { event, .. } => {
|
|
let ev = KeyEvent {
|
|
key: event.logical_key.clone(),
|
|
state: match event.state {
|
|
ElementState::Pressed => KeyState::Pressed,
|
|
ElementState::Released => KeyState::Released,
|
|
},
|
|
text: event.text.as_ref().map(|t| t.to_string()),
|
|
modifiers: self.secondaries[idx].modifiers,
|
|
repeat: event.repeat,
|
|
};
|
|
let msg = self
|
|
.state
|
|
.as_ref()
|
|
.and_then(|p| A::on_key(p.model.as_ref().expect("model"), &ev));
|
|
if let Some(msg) = msg {
|
|
self.dispatch_and_render_secondary(idx, msg);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|