From 50aad4bf84d5f705bd71c7adc2bb8ae479d2c64f Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 7 Apr 2026 15:32:13 -0700 Subject: [PATCH] add iced viewport crate with wgpu/metal surface embedded in swift app --- Cargo.toml | 6 ++ build.sh | 18 ++-- core/Cargo.toml | 5 +- src/ContentView.swift | 18 ++-- src/IcedViewportView.swift | 181 +++++++++++++++++++++++++++++++++++++ viewport/Cargo.toml | 21 +++++ viewport/build.rs | 6 ++ viewport/cbindgen.toml | 12 +++ viewport/include/swiftly.h | 44 +++++++++ viewport/src/bridge.rs | 88 ++++++++++++++++++ viewport/src/editor.rs | 27 ++++++ viewport/src/handle.rs | 180 ++++++++++++++++++++++++++++++++++++ viewport/src/lib.rs | 129 ++++++++++++++++++++++++++ 13 files changed, 717 insertions(+), 18 deletions(-) create mode 100644 Cargo.toml create mode 100644 src/IcedViewportView.swift create mode 100644 viewport/Cargo.toml create mode 100644 viewport/build.rs create mode 100644 viewport/cbindgen.toml create mode 100644 viewport/include/swiftly.h create mode 100644 viewport/src/bridge.rs create mode 100644 viewport/src/editor.rs create mode 100644 viewport/src/handle.rs create mode 100644 viewport/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5817dea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["core", "viewport"] +resolver = "2" + +[profile.release] +panic = "abort" diff --git a/build.sh b/build.sh index d713b46..cc1382a 100755 --- a/build.sh +++ b/build.sh @@ -10,23 +10,22 @@ RESOURCES="$CONTENTS/Resources" SDK=$(xcrun --show-sdk-path) -RUST_LIB="$ROOT/core/target/release" +RUST_LIB="$ROOT/target/release" export MACOSX_DEPLOYMENT_TARGET=14.0 export ZERO_AR_DATE=0 -echo "Building Rust core (release)..." -cd "$ROOT/core" && cargo build --release +echo "Building Rust workspace (release)..." +cd "$ROOT" && cargo build --release -p swiftly-viewport if [ $? -ne 0 ]; then echo "ERROR: Rust build failed" exit 1 fi -cd "$ROOT" -if [ ! -f "$RUST_LIB/libswiftly_core.a" ]; then - echo "ERROR: libswiftly_core.a not found at $RUST_LIB" +if [ ! -f "$RUST_LIB/libswiftly_viewport.a" ]; then + echo "ERROR: libswiftly_viewport.a not found at $RUST_LIB" exit 1 fi -RUST_FLAGS=(-import-objc-header "$ROOT/core/include/swiftly.h" -L "$RUST_LIB" -lswiftly_core) +RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/swiftly.h" -L "$RUST_LIB" -lswiftly_viewport) # --- App icon from pre-rendered PNGs --- ICONS="$ROOT/assets/icon_sources" @@ -63,6 +62,11 @@ swiftc \ "${RUST_FLAGS[@]}" \ -framework Cocoa \ -framework SwiftUI \ + -framework Metal \ + -framework MetalKit \ + -framework QuartzCore \ + -framework CoreGraphics \ + -framework CoreFoundation \ -O \ -o "$MACOS/Swiftly" \ "$ROOT"/src/*.swift diff --git a/core/Cargo.toml b/core/Cargo.toml index 82d93df..582e3d6 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [lib] -crate-type = ["staticlib", "rlib"] +crate-type = ["rlib"] [dependencies] cord-expr = { path = "../../Cord/crates/cord-expr" } @@ -41,6 +41,3 @@ tree-sitter-make = "1" [build-dependencies] cbindgen = "0.27" - -[profile.release] -panic = "abort" diff --git a/src/ContentView.swift b/src/ContentView.swift index 14be47a..3eaaa82 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -6,13 +6,17 @@ struct ContentView: View { var body: some View { let _ = themeVersion - EditorView(state: state) - .frame(minWidth: 400) - .frame(minWidth: 700, minHeight: 400) - .background(Color(ns: Theme.current.base)) - .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in - themeVersion += 1 - } + HSplitView { + EditorView(state: state) + .frame(minWidth: 400) + IcedViewportRepresentable() + .frame(minWidth: 200) + } + .frame(minWidth: 700, minHeight: 400) + .background(Color(ns: Theme.current.base)) + .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in + themeVersion += 1 + } } } diff --git a/src/IcedViewportView.swift b/src/IcedViewportView.swift new file mode 100644 index 0000000..f59bd01 --- /dev/null +++ b/src/IcedViewportView.swift @@ -0,0 +1,181 @@ +import AppKit +import SwiftUI + +class IcedViewportView: NSView { + private var viewportHandle: OpaquePointer? + private var displayLink: CVDisplayLink? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + } + + override var isFlipped: Bool { true } + override var wantsUpdateLayer: Bool { true } + override var acceptsFirstResponder: Bool { true } + + // MARK: - Lifecycle + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil && viewportHandle == nil { + createViewport() + startDisplayLink() + } else if window == nil { + stopDisplayLink() + destroyViewport() + } + } + + private func createViewport() { + let scale = Float(window?.backingScaleFactor ?? 2.0) + let w = Float(bounds.width) + let h = Float(bounds.height) + let nsviewPtr = Unmanaged.passUnretained(self).toOpaque() + viewportHandle = viewport_create(nsviewPtr, w, h, scale) + } + + private func destroyViewport() { + guard let handle = viewportHandle else { return } + viewport_destroy(handle) + viewportHandle = nil + } + + deinit { + stopDisplayLink() + destroyViewport() + } + + // MARK: - Display Link + + private func startDisplayLink() { + guard displayLink == nil else { return } + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link = link else { return } + + let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, userInfo -> CVReturn in + guard let userInfo = userInfo else { return kCVReturnSuccess } + let view = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + DispatchQueue.main.async { + view.renderFrame() + } + return kCVReturnSuccess + }, selfPtr) + + CVDisplayLinkStart(link) + displayLink = link + } + + private func stopDisplayLink() { + guard let link = displayLink else { return } + CVDisplayLinkStop(link) + displayLink = nil + } + + private func renderFrame() { + guard let handle = viewportHandle else { return } + viewport_render(handle) + } + + // MARK: - Resize + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + resizeViewport() + } + + override func setBoundsSize(_ newSize: NSSize) { + super.setBoundsSize(newSize) + resizeViewport() + } + + private func resizeViewport() { + guard let handle = viewportHandle else { return } + let scale = Float(window?.backingScaleFactor ?? 2.0) + viewport_resize(handle, Float(bounds.width), Float(bounds.height), scale) + } + + // MARK: - Mouse Events + + override func mouseDown(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_mouse_event(h, Float(pt.x), Float(pt.y), 0, true) + } + + override func mouseUp(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_mouse_event(h, Float(pt.x), Float(pt.y), 0, false) + } + + override func mouseMoved(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_mouse_event(h, Float(pt.x), Float(pt.y), 255, false) + } + + override func mouseDragged(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_mouse_event(h, Float(pt.x), Float(pt.y), 0, true) + } + + override func rightMouseDown(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_mouse_event(h, Float(pt.x), Float(pt.y), 1, true) + } + + override func rightMouseUp(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_mouse_event(h, Float(pt.x), Float(pt.y), 1, false) + } + + override func scrollWheel(with event: NSEvent) { + guard let h = viewportHandle else { return } + let pt = convert(event.locationInWindow, from: nil) + viewport_scroll_event(h, Float(pt.x), Float(pt.y), Float(event.scrollingDeltaX), Float(event.scrollingDeltaY)) + } + + // MARK: - Key Events + + override func keyDown(with event: NSEvent) { + guard let h = viewportHandle else { return } + let text = event.characters ?? "" + text.withCString { cstr in + viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, cstr) + } + } + + override func keyUp(with event: NSEvent) { + guard let h = viewportHandle else { return } + let text = event.characters ?? "" + text.withCString { cstr in + viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), false, cstr) + } + } + + override func flagsChanged(with event: NSEvent) { + guard let h = viewportHandle else { return } + viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, nil) + } +} + +// MARK: - SwiftUI Bridge + +struct IcedViewportRepresentable: NSViewRepresentable { + func makeNSView(context: Context) -> IcedViewportView { + IcedViewportView(frame: .zero) + } + + func updateNSView(_ nsView: IcedViewportView, context: Context) {} +} diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml new file mode 100644 index 0000000..1da93ba --- /dev/null +++ b/viewport/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "swiftly-viewport" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +swiftly-core = { path = "../core" } +iced_wgpu = "0.14" +iced_graphics = "0.14" +iced_runtime = "0.14" +iced_widget = { version = "0.14", features = ["wgpu"] } +wgpu = "27" +raw-window-handle = "0.6" +pollster = "0.4" +smol_str = "0.2" + +[build-dependencies] +cbindgen = "0.27" diff --git a/viewport/build.rs b/viewport/build.rs new file mode 100644 index 0000000..5bd5125 --- /dev/null +++ b/viewport/build.rs @@ -0,0 +1,6 @@ +fn main() { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + cbindgen::generate(&crate_dir) + .expect("cbindgen failed") + .write_to_file(format!("{}/include/swiftly.h", crate_dir)); +} diff --git a/viewport/cbindgen.toml b/viewport/cbindgen.toml new file mode 100644 index 0000000..9289213 --- /dev/null +++ b/viewport/cbindgen.toml @@ -0,0 +1,12 @@ +language = "C" +include_guard = "SWIFTLY_VIEWPORT_H" +header = """ +#include +#include +#include +#include +#include "../../core/include/swiftly.h" +""" + +[parse] +parse_deps = false diff --git a/viewport/include/swiftly.h b/viewport/include/swiftly.h new file mode 100644 index 0000000..9c9007b --- /dev/null +++ b/viewport/include/swiftly.h @@ -0,0 +1,44 @@ +#include +#include +#include +#include +#include "../../core/include/swiftly.h" + + +#ifndef SWIFTLY_VIEWPORT_H +#define SWIFTLY_VIEWPORT_H + +#include +#include +#include +#include + +typedef struct ViewportHandle ViewportHandle; + +struct ViewportHandle *viewport_create(void *nsview, float width, float height, float scale); + +void viewport_destroy(struct ViewportHandle *handle); + +void viewport_render(struct ViewportHandle *handle); + +void viewport_resize(struct ViewportHandle *handle, float width, float height, float scale); + +void viewport_mouse_event(struct ViewportHandle *handle, + float x, + float y, + uint8_t button, + bool pressed); + +void viewport_key_event(struct ViewportHandle *handle, + uint32_t key, + uint32_t modifiers, + bool pressed, + const char *text); + +void viewport_scroll_event(struct ViewportHandle *handle, + float x, + float y, + float delta_x, + float delta_y); + +#endif /* SWIFTLY_VIEWPORT_H */ diff --git a/viewport/src/bridge.rs b/viewport/src/bridge.rs new file mode 100644 index 0000000..dfb9afb --- /dev/null +++ b/viewport/src/bridge.rs @@ -0,0 +1,88 @@ +use iced_wgpu::core::keyboard::{self, key}; +use iced_wgpu::core::mouse; +use iced_wgpu::core::{Event, Point}; +use smol_str::SmolStr; + +use crate::ViewportHandle; + +pub fn push_mouse_event(handle: &mut ViewportHandle, x: f32, y: f32, button: u8, pressed: bool) { + let position = Point::new(x / handle.scale, y / handle.scale); + handle.cursor = mouse::Cursor::Available(position); + + handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position })); + + let btn = match button { + 0 => mouse::Button::Left, + 1 => mouse::Button::Right, + 2 => mouse::Button::Middle, + n => mouse::Button::Other(n as u16), + }; + + if pressed { + handle.events.push(Event::Mouse(mouse::Event::ButtonPressed(btn))); + } else { + handle.events.push(Event::Mouse(mouse::Event::ButtonReleased(btn))); + } +} + +pub fn push_key_event( + handle: &mut ViewportHandle, + keycode: u32, + modifier_flags: u32, + pressed: bool, + text: Option<&str>, +) { + let modifiers = decode_modifiers(modifier_flags); + let physical = key::Physical::Unidentified(key::NativeCode::MacOS(keycode as u16)); + let logical = text + .filter(|s| !s.is_empty()) + .map(|s| keyboard::Key::Character(SmolStr::new(s))) + .unwrap_or(keyboard::Key::Unidentified); + + if pressed { + handle.events.push(Event::Keyboard(keyboard::Event::KeyPressed { + key: logical.clone(), + modified_key: logical, + physical_key: physical, + location: keyboard::Location::Standard, + modifiers, + text: text.filter(|s| !s.is_empty()).map(SmolStr::new), + repeat: false, + })); + } else { + handle.events.push(Event::Keyboard(keyboard::Event::KeyReleased { + key: logical.clone(), + modified_key: logical, + physical_key: physical, + location: keyboard::Location::Standard, + modifiers, + })); + } +} + +pub fn push_scroll_event( + handle: &mut ViewportHandle, + x: f32, + y: f32, + delta_x: f32, + delta_y: f32, +) { + let position = Point::new(x / handle.scale, y / handle.scale); + handle.cursor = mouse::Cursor::Available(position); + handle.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { + x: delta_x, + y: delta_y, + }, + })); +} + +fn decode_modifiers(flags: u32) -> keyboard::Modifiers { + let mut m = keyboard::Modifiers::empty(); + // NSEvent modifier flags + if flags & (1 << 17) != 0 { m |= keyboard::Modifiers::SHIFT; } + if flags & (1 << 18) != 0 { m |= keyboard::Modifiers::CTRL; } + if flags & (1 << 19) != 0 { m |= keyboard::Modifiers::ALT; } + if flags & (1 << 20) != 0 { m |= keyboard::Modifiers::LOGO; } + m +} diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs new file mode 100644 index 0000000..f2a21e8 --- /dev/null +++ b/viewport/src/editor.rs @@ -0,0 +1,27 @@ +use iced_wgpu::core::{Color, Element, Length, Theme}; +use iced_widget::{container, Text}; + +pub struct EditorState { + pub text: String, +} + +impl EditorState { + pub fn new() -> Self { + Self { + text: String::from("Swiftly"), + } + } + + pub fn view(&self) -> Element<'_, (), Theme, iced_wgpu::Renderer> { + container( + Text::new(&self.text) + .size(32) + .color(Color::WHITE), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } +} diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs new file mode 100644 index 0000000..4118200 --- /dev/null +++ b/viewport/src/handle.rs @@ -0,0 +1,180 @@ +use std::ffi::c_void; +use std::ptr::NonNull; + +use iced_graphics::{Viewport, Shell}; +use iced_runtime::user_interface::{self, UserInterface}; +use iced_wgpu::core::renderer::Style; +use iced_wgpu::core::{clipboard, mouse, Color, Font, Pixels, Size, Theme}; +use iced_wgpu::Engine; +use raw_window_handle::{AppKitDisplayHandle, AppKitWindowHandle, RawDisplayHandle, RawWindowHandle}; + +use crate::editor::EditorState; +use crate::ViewportHandle; + +pub fn create(nsview: *mut c_void, width: f32, height: f32, scale: f32) -> Option { + let ptr = NonNull::new(nsview)?; + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::METAL, + ..Default::default() + }); + + let raw_window = RawWindowHandle::AppKit(AppKitWindowHandle::new(ptr)); + let raw_display = RawDisplayHandle::AppKit(AppKitDisplayHandle::new()); + + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: raw_display, + raw_window_handle: raw_window, + }; + + let surface = unsafe { instance.create_surface_unsafe(target).ok()? }; + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + })) + .ok()?; + + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + + let caps = surface.get_capabilities(&adapter); + let format = caps.formats.first().copied()?; + + surface.configure( + &device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: phys_w.max(1), + height: phys_h.max(1), + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto), + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); + + let engine = Engine::new( + &adapter, + device.clone(), + queue.clone(), + format, + None, + Shell::headless(), + ); + + let renderer = iced_wgpu::Renderer::new( + engine, + Font::DEFAULT, + Pixels(16.0), + ); + + let viewport = Viewport::with_physical_size( + Size::new(phys_w.max(1), phys_h.max(1)), + scale, + ); + + Some(ViewportHandle { + surface, + device, + queue, + format, + width: phys_w, + height: phys_h, + scale, + renderer, + viewport, + cache: user_interface::Cache::new(), + state: EditorState::new(), + events: Vec::new(), + cursor: mouse::Cursor::Unavailable, + }) +} + +pub fn render(handle: &mut ViewportHandle) { + let frame = match handle.surface.get_current_texture() { + Ok(f) => f, + Err(_) => return, + }; + let view = frame.texture.create_view(&Default::default()); + + let logical_size = handle.viewport.logical_size(); + + let cache = std::mem::take(&mut handle.cache); + let mut ui = UserInterface::build( + handle.state.view(), + Size::new(logical_size.width, logical_size.height), + cache, + &mut handle.renderer, + ); + + let mut clipboard = clipboard::Null; + let mut messages: Vec<()> = Vec::new(); + + let _ = ui.update( + &handle.events, + handle.cursor, + &mut handle.renderer, + &mut clipboard, + &mut messages, + ); + handle.events.clear(); + + let theme = Theme::Dark; + let style = Style { + text_color: Color::WHITE, + }; + + ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); + handle.cache = ui.into_cache(); + + let bg = Color::from_rgb(0.08, 0.08, 0.10); + handle.renderer.present( + Some(bg), + handle.format, + &view, + &handle.viewport, + ); + + frame.present(); +} + +pub fn resize(handle: &mut ViewportHandle, width: f32, height: f32, scale: f32) { + let phys_w = (width * scale) as u32; + let phys_h = (height * scale) as u32; + if phys_w == 0 || phys_h == 0 { + return; + } + + handle.width = phys_w; + handle.height = phys_h; + handle.scale = scale; + + handle.viewport = Viewport::with_physical_size( + Size::new(phys_w, phys_h), + scale, + ); + + handle.surface.configure( + &handle.device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: handle.format, + width: phys_w, + height: phys_h, + present_mode: wgpu::PresentMode::AutoVsync, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }, + ); +} diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs new file mode 100644 index 0000000..12ba428 --- /dev/null +++ b/viewport/src/lib.rs @@ -0,0 +1,129 @@ +use std::ffi::{c_char, c_void}; + +mod bridge; +mod editor; +mod handle; + +pub use swiftly_core::*; + +use editor::EditorState; +use iced_graphics::Viewport; +use iced_runtime::user_interface; +use iced_wgpu::core::Event; + +#[allow(dead_code)] +pub struct ViewportHandle { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + format: wgpu::TextureFormat, + width: u32, + height: u32, + scale: f32, + + renderer: iced_wgpu::Renderer, + viewport: Viewport, + cache: user_interface::Cache, + state: EditorState, + events: Vec, + cursor: iced_wgpu::core::mouse::Cursor, +} + +#[no_mangle] +pub extern "C" fn viewport_create( + nsview: *mut c_void, + width: f32, + height: f32, + scale: f32, +) -> *mut ViewportHandle { + if nsview.is_null() { + return std::ptr::null_mut(); + } + match handle::create(nsview, width, height, scale) { + Some(h) => Box::into_raw(Box::new(h)), + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn viewport_destroy(handle: *mut ViewportHandle) { + if handle.is_null() { + return; + } + unsafe { + drop(Box::from_raw(handle)); + } +} + +#[no_mangle] +pub extern "C" fn viewport_render(handle: *mut ViewportHandle) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + handle::render(h); +} + +#[no_mangle] +pub extern "C" fn viewport_resize( + handle: *mut ViewportHandle, + width: f32, + height: f32, + scale: f32, +) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + handle::resize(h, width, height, scale); +} + +#[no_mangle] +pub extern "C" fn viewport_mouse_event( + handle: *mut ViewportHandle, + x: f32, + y: f32, + button: u8, + pressed: bool, +) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + bridge::push_mouse_event(h, x, y, button, pressed); +} + +#[no_mangle] +pub extern "C" fn viewport_key_event( + handle: *mut ViewportHandle, + key: u32, + modifiers: u32, + pressed: bool, + text: *const c_char, +) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + let text_str = if text.is_null() { + None + } else { + Some(unsafe { std::ffi::CStr::from_ptr(text) }.to_string_lossy()) + }; + bridge::push_key_event(h, key, modifiers, pressed, text_str.as_deref()); +} + +#[no_mangle] +pub extern "C" fn viewport_scroll_event( + handle: *mut ViewportHandle, + x: f32, + y: f32, + delta_x: f32, + delta_y: f32, +) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + bridge::push_scroll_event(h, x, y, delta_x, delta_y); +}