add iced viewport crate with wgpu/metal surface embedded in swift app

This commit is contained in:
jess 2026-04-07 15:32:13 -07:00
parent 3edc8838d9
commit 50aad4bf84
13 changed files with 717 additions and 18 deletions

6
Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
[workspace]
members = ["core", "viewport"]
resolver = "2"
[profile.release]
panic = "abort"

View File

@ -10,23 +10,22 @@ RESOURCES="$CONTENTS/Resources"
SDK=$(xcrun --show-sdk-path) SDK=$(xcrun --show-sdk-path)
RUST_LIB="$ROOT/core/target/release" RUST_LIB="$ROOT/target/release"
export MACOSX_DEPLOYMENT_TARGET=14.0 export MACOSX_DEPLOYMENT_TARGET=14.0
export ZERO_AR_DATE=0 export ZERO_AR_DATE=0
echo "Building Rust core (release)..." echo "Building Rust workspace (release)..."
cd "$ROOT/core" && cargo build --release cd "$ROOT" && cargo build --release -p swiftly-viewport
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "ERROR: Rust build failed" echo "ERROR: Rust build failed"
exit 1 exit 1
fi fi
cd "$ROOT"
if [ ! -f "$RUST_LIB/libswiftly_core.a" ]; then if [ ! -f "$RUST_LIB/libswiftly_viewport.a" ]; then
echo "ERROR: libswiftly_core.a not found at $RUST_LIB" echo "ERROR: libswiftly_viewport.a not found at $RUST_LIB"
exit 1 exit 1
fi 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 --- # --- App icon from pre-rendered PNGs ---
ICONS="$ROOT/assets/icon_sources" ICONS="$ROOT/assets/icon_sources"
@ -63,6 +62,11 @@ swiftc \
"${RUST_FLAGS[@]}" \ "${RUST_FLAGS[@]}" \
-framework Cocoa \ -framework Cocoa \
-framework SwiftUI \ -framework SwiftUI \
-framework Metal \
-framework MetalKit \
-framework QuartzCore \
-framework CoreGraphics \
-framework CoreFoundation \
-O \ -O \
-o "$MACOS/Swiftly" \ -o "$MACOS/Swiftly" \
"$ROOT"/src/*.swift "$ROOT"/src/*.swift

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[lib] [lib]
crate-type = ["staticlib", "rlib"] crate-type = ["rlib"]
[dependencies] [dependencies]
cord-expr = { path = "../../Cord/crates/cord-expr" } cord-expr = { path = "../../Cord/crates/cord-expr" }
@ -41,6 +41,3 @@ tree-sitter-make = "1"
[build-dependencies] [build-dependencies]
cbindgen = "0.27" cbindgen = "0.27"
[profile.release]
panic = "abort"

View File

@ -6,8 +6,12 @@ struct ContentView: View {
var body: some View { var body: some View {
let _ = themeVersion let _ = themeVersion
HSplitView {
EditorView(state: state) EditorView(state: state)
.frame(minWidth: 400) .frame(minWidth: 400)
IcedViewportRepresentable()
.frame(minWidth: 200)
}
.frame(minWidth: 700, minHeight: 400) .frame(minWidth: 700, minHeight: 400)
.background(Color(ns: Theme.current.base)) .background(Color(ns: Theme.current.base))
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in

181
src/IcedViewportView.swift Normal file
View File

@ -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<IcedViewportView>.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) {}
}

21
viewport/Cargo.toml Normal file
View File

@ -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"

6
viewport/build.rs Normal file
View File

@ -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));
}

12
viewport/cbindgen.toml Normal file
View File

@ -0,0 +1,12 @@
language = "C"
include_guard = "SWIFTLY_VIEWPORT_H"
header = """
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include "../../core/include/swiftly.h"
"""
[parse]
parse_deps = false

View File

@ -0,0 +1,44 @@
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include "../../core/include/swiftly.h"
#ifndef SWIFTLY_VIEWPORT_H
#define SWIFTLY_VIEWPORT_H
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
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 */

88
viewport/src/bridge.rs Normal file
View File

@ -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
}

27
viewport/src/editor.rs Normal file
View File

@ -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()
}
}

180
viewport/src/handle.rs Normal file
View File

@ -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<ViewportHandle> {
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,
},
);
}

129
viewport/src/lib.rs Normal file
View File

@ -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<Event>,
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);
}