add file I/O bridge between Swift and Iced editor via FFI text get/set

This commit is contained in:
jess 2026-04-07 19:02:18 -07:00
parent 60723a6071
commit 768ec6db2c
7 changed files with 450 additions and 28 deletions

View File

@ -26,10 +26,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow! var window: NSWindow!
var appState: AppState! var appState: AppState!
private var titleCancellable: AnyCancellable? private var titleCancellable: AnyCancellable?
private var textCancellable: AnyCancellable?
private var titleBarView: TitleBarView? private var titleBarView: TitleBarView?
private var focusTitleObserver: NSObjectProtocol? private var focusTitleObserver: NSObjectProtocol?
private var windowControllers: [WindowController] = [] private var windowControllers: [WindowController] = []
private var viewport: IcedViewportView? {
window?.contentView as? IcedViewportView
}
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
_ = ConfigManager.shared _ = ConfigManager.shared
appState = AppState() appState = AppState()
@ -57,6 +62,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setupMenuBar() setupMenuBar()
observeDocumentTitle() observeDocumentTitle()
observeDocumentText()
DocumentBrowserController.shared = DocumentBrowserController(appState: appState) DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@ -86,6 +93,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
syncTextFromViewport()
appState.saveNote() appState.saveNote()
} }
@ -301,6 +309,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc private func saveNote() { @objc private func saveNote() {
syncTextFromViewport()
if appState.currentFileURL != nil { if appState.currentFileURL != nil {
appState.saveNote() appState.saveNote()
} else { } else {
@ -309,6 +318,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc private func saveNoteAs() { @objc private func saveNoteAs() {
syncTextFromViewport()
let panel = NSSavePanel() let panel = NSSavePanel()
panel.allowedContentTypes = Self.supportedContentTypes panel.allowedContentTypes = Self.supportedContentTypes
panel.nameFieldStringValue = defaultFilename() panel.nameFieldStringValue = defaultFilename()
@ -466,6 +476,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
private func observeDocumentText() {
textCancellable = appState.$documentText
.receive(on: RunLoop.main)
.sink { [weak self] text in
self?.viewport?.setText(text)
}
}
private func syncTextFromViewport() {
guard let vp = viewport else { return }
let text = vp.getText()
if !text.isEmpty || appState.documentText.isEmpty {
appState.documentText = text
}
}
private func observeDocumentTitle() { private func observeDocumentTitle() {
titleCancellable = appState.$documentText titleCancellable = appState.$documentText
.receive(on: RunLoop.main) .receive(on: RunLoop.main)

View File

@ -2,7 +2,7 @@ import AppKit
import SwiftUI import SwiftUI
class IcedViewportView: NSView { class IcedViewportView: NSView {
private var viewportHandle: OpaquePointer? private(set) var viewportHandle: OpaquePointer?
private var displayLink: CVDisplayLink? private var displayLink: CVDisplayLink?
override init(frame frameRect: NSRect) { override init(frame frameRect: NSRect) {
@ -159,7 +159,7 @@ class IcedViewportView: NSView {
if cmd && !shift { if cmd && !shift {
switch chars { switch chars {
case "a", "c", "v", "x", "z", "p", "t", case "a", "b", "c", "i", "v", "x", "z", "p", "t",
"=", "+", "-", "0": "=", "+", "-", "0":
keyDown(with: event) keyDown(with: event)
return true return true
@ -197,4 +197,21 @@ class IcedViewportView: NSView {
guard let h = viewportHandle else { return } guard let h = viewportHandle else { return }
viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, nil) viewport_key_event(h, UInt32(event.keyCode), UInt32(event.modifierFlags.rawValue), true, nil)
} }
// MARK: - Text Bridge
func setText(_ text: String) {
guard let h = viewportHandle else { return }
text.withCString { cstr in
viewport_set_text(h, cstr)
}
}
func getText() -> String {
guard let h = viewportHandle else { return "" }
guard let cstr = viewport_get_text(h) else { return "" }
let result = String(cString: cstr)
viewport_free_string(cstr)
return result
}
} }

View File

@ -41,4 +41,12 @@ void viewport_scroll_event(struct ViewportHandle *handle,
float delta_x, float delta_x,
float delta_y); float delta_y);
void viewport_set_text(struct ViewportHandle *handle, const char *text);
void viewport_set_lang(struct ViewportHandle *handle, const char *ext);
char *viewport_get_text(struct ViewportHandle *handle);
void viewport_free_string(char *s);
#endif /* SWIFTLY_VIEWPORT_H */ #endif /* SWIFTLY_VIEWPORT_H */

View File

@ -9,13 +9,20 @@ use iced_wgpu::core::{
use iced_widget::container; use iced_widget::container;
use iced_widget::markdown; use iced_widget::markdown;
use iced_widget::text_editor::{self, Binding, KeyPress, Motion, Status, Style}; use iced_widget::text_editor::{self, Binding, KeyPress, Motion, Status, Style};
use iced_wgpu::core::text::highlighter::Format;
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Message { pub enum Message {
EditorAction(text_editor::Action), EditorAction(text_editor::Action),
TogglePreview, TogglePreview,
MarkdownLink(markdown::Uri), MarkdownLink(markdown::Uri),
InsertTable, InsertTable,
ToggleBold,
ToggleItalic,
Evaluate,
SmartEval,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
ZoomReset, ZoomReset,
@ -26,6 +33,9 @@ pub struct EditorState {
pub font_size: f32, pub font_size: f32,
pub preview: bool, pub preview: bool,
pub parsed: Vec<markdown::Item>, pub parsed: Vec<markdown::Item>,
pub eval_results: Vec<(usize, String)>,
pub eval_errors: Vec<(usize, String)>,
pub lang: Option<String>,
} }
fn md_style() -> markdown::Style { fn md_style() -> markdown::Style {
@ -46,41 +56,90 @@ fn md_style() -> markdown::Style {
impl EditorState { impl EditorState {
pub fn new() -> Self { pub fn new() -> Self {
let sample = concat!( let sample = concat!(
"# Heading 1\n\n", "use std::collections::HashMap;\n\n",
"## Heading 2\n\n", "/// A simple key-value store.\n",
"### Heading 3\n\n", "pub struct Store {\n",
"Regular text with **bold** and *italic* and `inline code`.\n\n", " data: HashMap<String, i64>,\n",
"- Bullet one\n", "}\n\n",
"- Bullet two\n", "impl Store {\n",
"- Bullet three\n\n", " pub fn new() -> Self {\n",
"1. First\n", " Self { data: HashMap::new() }\n",
"2. Second\n", " }\n\n",
"3. Third\n\n", " pub fn insert(&mut self, key: &str, value: i64) {\n",
"> This is a blockquote\n\n", " self.data.insert(key.to_string(), value);\n",
"```python\n", " }\n\n",
"def hello():\n", " pub fn get(&self, key: &str) -> Option<&i64> {\n",
" print(\"Hello world\")\n", " self.data.get(key)\n",
"```\n\n", " }\n",
"| Name | Age | City |\n", "}\n\n",
"|------|-----|------|\n", "fn main() {\n",
"| Alice | 30 | NYC |\n", " let mut store = Store::new();\n",
"| Bob | 25 | LA |\n\n", " store.insert(\"count\", 42);\n",
"---\n\n", " if let Some(val) = store.get(\"count\") {\n",
"[Link text](https://example.com)\n", " println!(\"value: {val}\");\n",
" }\n",
"}\n",
); );
Self { Self {
content: text_editor::Content::with_text(sample), content: text_editor::Content::with_text(sample),
font_size: 14.0, font_size: 14.0,
preview: false, preview: false,
parsed: Vec::new(), parsed: Vec::new(),
eval_results: Vec::new(),
eval_errors: Vec::new(),
lang: Some("rust".into()),
} }
} }
pub fn set_text(&mut self, text: &str) {
self.content = text_editor::Content::with_text(text);
self.reparse();
}
pub fn set_lang_from_ext(&mut self, ext: &str) {
self.lang = lang_from_extension(ext);
}
fn reparse(&mut self) { fn reparse(&mut self) {
let text = self.content.text(); let text = self.content.text();
self.parsed = markdown::parse(&text).collect(); self.parsed = markdown::parse(&text).collect();
} }
fn toggle_wrap(&mut self, marker: &str) {
let mlen = marker.len();
match self.content.selection() {
Some(sel) if sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= mlen * 2 => {
let inner = &sel[mlen..sel.len() - mlen];
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(inner.to_string())),
));
}
Some(sel) => {
let wrapped = format!("{marker}{sel}{marker}");
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(wrapped)),
));
}
None => {
let empty = format!("{marker}{marker}");
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(empty)),
));
for _ in 0..mlen {
self.content.perform(text_editor::Action::Move(Motion::Left));
}
}
}
self.reparse();
}
fn run_eval(&mut self) {
let text = self.content.text();
let doc = crate::eval::evaluate_document(&text);
self.eval_results = doc.results.into_iter().map(|r| (r.line, r.result)).collect();
self.eval_errors = doc.errors.into_iter().map(|e| (e.line, e.error)).collect();
}
pub fn update(&mut self, message: Message) { pub fn update(&mut self, message: Message) {
match message { match message {
Message::EditorAction(action) => { Message::EditorAction(action) => {
@ -88,6 +147,7 @@ impl EditorState {
self.content.perform(action); self.content.perform(action);
if is_edit { if is_edit {
self.reparse(); self.reparse();
self.run_eval();
} }
} }
Message::InsertTable => { Message::InsertTable => {
@ -96,6 +156,34 @@ impl EditorState {
text_editor::Edit::Paste(Arc::new(table.to_string())), text_editor::Edit::Paste(Arc::new(table.to_string())),
)); ));
self.reparse(); self.reparse();
self.run_eval();
}
Message::ToggleBold => {
self.toggle_wrap("**");
}
Message::ToggleItalic => {
self.toggle_wrap("*");
}
Message::Evaluate => {
self.run_eval();
}
Message::SmartEval => {
let cursor = self.content.cursor();
let text = self.content.text();
let lines: Vec<&str> = text.lines().collect();
let line_idx = cursor.position.line;
if line_idx < lines.len() {
let line = lines[line_idx].trim();
if let Some(varname) = parse_let_binding(line) {
let insert = format!("\n/= {varname}");
self.content.perform(text_editor::Action::Move(Motion::End));
self.content.perform(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(insert)),
));
self.reparse();
self.run_eval();
}
}
} }
Message::TogglePreview => { Message::TogglePreview => {
self.preview = !self.preview; self.preview = !self.preview;
@ -140,7 +228,7 @@ impl EditorState {
}) })
.into() .into()
} else { } else {
iced_widget::text_editor(&self.content) let editor = iced_widget::text_editor(&self.content)
.on_action(Message::EditorAction) .on_action(Message::EditorAction)
.font(Font::MONOSPACE) .font(Font::MONOSPACE)
.size(self.font_size) .size(self.font_size)
@ -154,8 +242,25 @@ impl EditorState {
placeholder: Color::from_rgb(0.4, 0.4, 0.4), placeholder: Color::from_rgb(0.4, 0.4, 0.4),
value: Color::WHITE, value: Color::WHITE,
selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4), selection: Color::from_rgba(0.3, 0.5, 0.8, 0.4),
}) });
.into()
if let Some(lang) = &self.lang {
let settings = SyntaxSettings {
lang: lang.clone(),
source: self.content.text(),
};
editor
.highlight_with::<SyntaxHighlighter>(
settings,
|highlight, _theme| Format {
color: Some(syntax::highlight_color(highlight.kind)),
font: None,
},
)
.into()
} else {
editor.into()
}
}; };
let mode_label = if self.preview { "Preview" } else { "Edit" }; let mode_label = if self.preview { "Preview" } else { "Edit" };
@ -182,12 +287,73 @@ impl EditorState {
snap: false, snap: false,
}); });
iced_widget::column([main_content, status_bar.into()]) let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> =
vec![main_content];
if !self.eval_results.is_empty() || !self.eval_errors.is_empty() {
let mut result_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for (ln, val) in &self.eval_results {
result_items.push(
iced_widget::text(format!("Ln {}: {}", ln + 1, val))
.font(Font::MONOSPACE)
.size(11.0)
.color(Color::from_rgb(0.651, 0.890, 0.631))
.into(),
);
}
for (ln, err) in &self.eval_errors {
result_items.push(
iced_widget::text(format!("Ln {}: {}", ln + 1, err))
.font(Font::MONOSPACE)
.size(11.0)
.color(Color::from_rgb(0.890, 0.400, 0.400))
.into(),
);
}
let eval_panel = iced_widget::container(
iced_widget::column(result_items).spacing(2.0),
)
.width(Length::Fill)
.padding(Padding { top: 4.0, right: 10.0, bottom: 4.0, left: 10.0 })
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.12))),
border: Border::default(),
text_color: None,
shadow: Shadow::default(),
snap: false,
});
col_items.push(eval_panel.into());
}
col_items.push(status_bar.into());
iced_widget::column(col_items)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} }
} }
fn parse_let_binding(line: &str) -> Option<String> {
let rest = line.strip_prefix("let ")?;
let eq_pos = rest.find('=')?;
if rest.as_bytes().get(eq_pos + 1) == Some(&b'=') {
return None;
}
let name_part = rest[..eq_pos].trim();
let name = if let Some(colon) = name_part.find(':') {
name_part[..colon].trim()
} else {
name_part
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some(name.to_string())
}
fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> { fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> {
let KeyPress { key, modifiers, status, .. } = &key_press; let KeyPress { key, modifiers, status, .. } = &key_press;
@ -232,3 +398,35 @@ fn macos_key_binding(key_press: KeyPress) -> Option<Binding<Message>> {
_ => Binding::from_key_press(key_press), _ => Binding::from_key_press(key_press),
} }
} }
fn lang_from_extension(ext: &str) -> Option<String> {
let lang = match ext {
"rs" => "rust",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
"js" | "mjs" | "cjs" => "javascript",
"jsx" => "jsx",
"ts" | "mts" | "cts" => "typescript",
"tsx" => "tsx",
"py" => "python",
"go" => "go",
"rb" => "ruby",
"sh" | "bash" | "zsh" => "bash",
"java" => "java",
"html" | "htm" => "html",
"css" => "css",
"scss" => "scss",
"less" => "less",
"json" => "json",
"lua" => "lua",
"php" => "php",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"swift" => "swift",
"zig" => "zig",
"sql" => "sql",
"mk" => "make",
_ => return None,
};
Some(lang.to_string())
}

View File

@ -168,6 +168,9 @@ pub fn render(handle: &mut ViewportHandle) {
match c.as_str() { match c.as_str() {
"p" => messages.push(Message::TogglePreview), "p" => messages.push(Message::TogglePreview),
"t" => messages.push(Message::InsertTable), "t" => messages.push(Message::InsertTable),
"b" => messages.push(Message::ToggleBold),
"i" => messages.push(Message::ToggleItalic),
"e" => messages.push(Message::SmartEval),
_ => {} _ => {}
} }
} }

View File

@ -1,8 +1,9 @@
use std::ffi::{c_char, c_void}; use std::ffi::{c_char, c_void, CStr, CString};
mod bridge; mod bridge;
mod editor; mod editor;
mod handle; mod handle;
mod syntax;
pub use swiftly_core::*; pub use swiftly_core::*;
@ -127,3 +128,47 @@ pub extern "C" fn viewport_scroll_event(
}; };
bridge::push_scroll_event(h, x, y, delta_x, delta_y); bridge::push_scroll_event(h, x, y, delta_x, delta_y);
} }
#[no_mangle]
pub extern "C" fn viewport_set_text(handle: *mut ViewportHandle, text: *const c_char) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
let s = if text.is_null() {
""
} else {
unsafe { CStr::from_ptr(text) }.to_str().unwrap_or("")
};
h.state.set_text(s);
}
#[no_mangle]
pub extern "C" fn viewport_set_lang(handle: *mut ViewportHandle, ext: *const c_char) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
if ext.is_null() {
h.state.lang = None;
} else {
let s = unsafe { CStr::from_ptr(ext) }.to_str().unwrap_or("");
h.state.set_lang_from_ext(s);
}
}
#[no_mangle]
pub extern "C" fn viewport_get_text(handle: *mut ViewportHandle) -> *mut c_char {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return std::ptr::null_mut(),
};
let text = h.state.content.text();
CString::new(text).unwrap_or_default().into_raw()
}
#[no_mangle]
pub extern "C" fn viewport_free_string(s: *mut c_char) {
if s.is_null() { return; }
unsafe { drop(CString::from_raw(s)); }
}

125
viewport/src/syntax.rs Normal file
View File

@ -0,0 +1,125 @@
use std::ops::Range;
use iced_wgpu::core::text::highlighter;
use iced_wgpu::core::Color;
use swiftly_core::highlight::{highlight_source, HighlightSpan};
#[derive(Clone, PartialEq)]
pub struct SyntaxSettings {
pub lang: String,
pub source: String,
}
#[derive(Clone, Copy, Debug)]
pub struct SyntaxHighlight {
pub kind: u8,
}
pub struct SyntaxHighlighter {
lang: String,
spans: Vec<HighlightSpan>,
line_offsets: Vec<usize>,
current_line: usize,
}
impl SyntaxHighlighter {
fn rebuild(&mut self, source: &str) {
self.spans = highlight_source(source, &self.lang);
self.line_offsets.clear();
let mut offset = 0;
for line in source.split('\n') {
self.line_offsets.push(offset);
offset += line.len() + 1;
}
self.current_line = 0;
}
}
impl highlighter::Highlighter for SyntaxHighlighter {
type Settings = SyntaxSettings;
type Highlight = SyntaxHighlight;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, SyntaxHighlight)>;
fn new(settings: &Self::Settings) -> Self {
let mut h = SyntaxHighlighter {
lang: settings.lang.clone(),
spans: Vec::new(),
line_offsets: Vec::new(),
current_line: 0,
};
h.rebuild(&settings.source);
h
}
fn update(&mut self, new_settings: &Self::Settings) {
self.lang = new_settings.lang.clone();
self.rebuild(&new_settings.source);
}
fn change_line(&mut self, line: usize) {
self.current_line = self.current_line.min(line);
}
fn highlight_line(&mut self, _line: &str) -> Self::Iterator<'_> {
let ln = self.current_line;
self.current_line += 1;
if ln >= self.line_offsets.len() {
return Vec::new().into_iter();
}
let line_start = self.line_offsets[ln];
let line_end = if ln + 1 < self.line_offsets.len() {
self.line_offsets[ln + 1] - 1
} else {
line_start + _line.len()
};
let mut result = Vec::new();
for span in &self.spans {
if span.end <= line_start || span.start >= line_end {
continue;
}
let start = span.start.max(line_start) - line_start;
let end = span.end.min(line_end) - line_start;
if start < end {
result.push((start..end, SyntaxHighlight { kind: span.kind }));
}
}
result.into_iter()
}
fn current_line(&self) -> usize {
self.current_line
}
}
pub fn highlight_color(kind: u8) -> Color {
match kind {
0 => Color::from_rgb(0.804, 0.569, 0.945), // keyword - mauve
1 => Color::from_rgb(0.537, 0.706, 0.980), // function - blue
2 => Color::from_rgb(0.604, 0.831, 0.898), // function.builtin - teal
3 => Color::from_rgb(0.976, 0.827, 0.522), // type - yellow
4 => Color::from_rgb(0.976, 0.827, 0.522), // type.builtin - yellow
5 => Color::from_rgb(0.569, 0.878, 0.800), // constructor - teal
6 => Color::from_rgb(0.988, 0.702, 0.529), // constant - peach
7 => Color::from_rgb(0.988, 0.702, 0.529), // constant.builtin - peach
8 => Color::from_rgb(0.651, 0.890, 0.631), // string - green
9 => Color::from_rgb(0.988, 0.702, 0.529), // number - peach
10 => Color::from_rgb(0.424, 0.443, 0.537), // comment - overlay0
11 => Color::from_rgb(0.804, 0.839, 0.957), // variable - text
12 => Color::from_rgb(0.949, 0.604, 0.584), // variable.builtin - red
13 => Color::from_rgb(0.949, 0.773, 0.584), // variable.parameter - flamingo
14 => Color::from_rgb(0.604, 0.831, 0.898), // operator - sky
15 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation - overlay2
16 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation.bracket - overlay2
17 => Color::from_rgb(0.580, 0.612, 0.733), // punctuation.delimiter - overlay2
18 => Color::from_rgb(0.537, 0.706, 0.980), // property - blue
19 => Color::from_rgb(0.804, 0.569, 0.945), // tag - mauve
20 => Color::from_rgb(0.976, 0.827, 0.522), // attribute - yellow
21 => Color::from_rgb(0.569, 0.878, 0.800), // label - teal
22 => Color::from_rgb(0.949, 0.604, 0.584), // escape - red
23 => Color::from_rgb(0.804, 0.839, 0.957), // embedded - text
_ => Color::from_rgb(0.804, 0.839, 0.957), // default text
}
}