diff --git a/Cargo.toml b/Cargo.toml index 5817dea..5a65e64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["core", "viewport"] +members = ["core", "viewport", "windows"] resolver = "2" [profile.release] diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index eca25e3..242054e 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -39,8 +39,8 @@ pub struct ViewportHandle { renderer: iced_wgpu::Renderer, viewport: Viewport, cache: user_interface::Cache, - state: EditorState, - events: Vec, + pub state: EditorState, + pub events: Vec, cursor: iced_wgpu::core::mouse::Cursor, /// Set true on any FFI input or state-change call. handle::render() early-returns /// when this is false AND no pending eval debounce, so the vsync display link diff --git a/windows/Cargo.toml b/windows/Cargo.toml new file mode 100644 index 0000000..600ff5c --- /dev/null +++ b/windows/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "acord-windows" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "acord" +path = "src/main.rs" + +[dependencies] +acord-core = { path = "../core" } +acord-viewport = { path = "../viewport" } +winit = "0.30" +muda = "0.16" +arboard = "3" +rfd = "0.15" +raw-window-handle = "0.6" +wgpu = "27" +iced_wgpu = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" + +[target.'cfg(windows)'.dependencies] +winapi = { version = "0.3", features = ["winuser"] } + +[target.'cfg(windows)'.build-dependencies] +winres = "0.1" diff --git a/windows/src/app.rs b/windows/src/app.rs new file mode 100644 index 0000000..9e95f61 --- /dev/null +++ b/windows/src/app.rs @@ -0,0 +1,364 @@ +use std::ffi::{c_void, CString}; + +use winit::application::ApplicationHandler; +use winit::dpi::{LogicalSize, PhysicalPosition}; +use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; +use winit::event_loop::ActiveEventLoop; +use winit::keyboard::{Key, NamedKey, ModifiersState}; +use winit::window::{Window, WindowAttributes, WindowId}; + +use acord_viewport::{ + viewport_create, viewport_destroy, viewport_render, viewport_resize, + viewport_set_text, viewport_get_text, viewport_set_theme, viewport_set_lang, + viewport_set_line_indicator, viewport_set_gutter_rainbow, + viewport_send_command, viewport_free_string, viewport_render_mode, + viewport_export_crate, ViewportHandle, +}; + +use crate::config::Config; +use crate::menu::{AppMenu, MenuAction}; + +pub struct App { + window: Option, + handle: *mut ViewportHandle, + config: Config, + menu: Option, + cursor_pos: PhysicalPosition, + scale: f32, +} + +impl App { + pub fn new() -> Self { + Self { + window: None, + handle: std::ptr::null_mut(), + config: Config::load(), + menu: None, + cursor_pos: PhysicalPosition::new(0.0, 0.0), + scale: 1.0, + } + } + + fn sync_settings(&self) { + if self.handle.is_null() { return; } + let theme = match self.config.theme_mode() { + "dark" => "kicad", + "light" => "latte", + _ => "kicad", // Windows: default dark. No NSAppearance auto-detect. + }; + let c_theme = CString::new(theme).unwrap(); + viewport_set_theme(self.handle, c_theme.as_ptr()); + + let ind = CString::new(self.config.line_indicator()).unwrap(); + viewport_set_line_indicator(self.handle, ind.as_ptr()); + viewport_set_gutter_rainbow(self.handle, self.config.gutter_rainbow()); + } + + fn dispatch_menu(&mut self, action: MenuAction, event_loop: &ActiveEventLoop) { + if self.handle.is_null() { return; } + match action { + MenuAction::Quit => event_loop.exit(), + MenuAction::Bold => { viewport_send_command(self.handle, 1); } + MenuAction::Italic => { viewport_send_command(self.handle, 2); } + MenuAction::InsertTable => { viewport_send_command(self.handle, 3); } + MenuAction::Evaluate => { viewport_send_command(self.handle, 5); } + MenuAction::ZoomIn => { viewport_send_command(self.handle, 7); } + MenuAction::ZoomOut => { viewport_send_command(self.handle, 8); } + MenuAction::ZoomReset => { viewport_send_command(self.handle, 9); } + MenuAction::LiveMode => { viewport_send_command(self.handle, 11); } + MenuAction::EditorMode => { viewport_send_command(self.handle, 12); } + MenuAction::ViewMode => { viewport_send_command(self.handle, 13); } + MenuAction::Find => { viewport_send_command(self.handle, 14); } + MenuAction::Open => self.open_file(), + MenuAction::Save => self.save_file(), + MenuAction::SaveAs => self.save_file_as(), + MenuAction::NewNote => self.new_note(), + MenuAction::Undo => { /* TODO */ }, + MenuAction::Redo => { /* TODO */ }, + MenuAction::ExportCrate => { /* TODO */ }, + } + } + + fn open_file(&mut self) { + let dialog = rfd::FileDialog::new() + .add_filter("Markdown", &["md", "markdown"]) + .add_filter("All Files", &["*"]); + if let Some(path) = dialog.pick_file() { + if let Ok(text) = std::fs::read_to_string(&path) { + let c = CString::new(text).unwrap_or_default(); + viewport_set_text(self.handle, c.as_ptr()); + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("md"); + let c_ext = CString::new(ext).unwrap(); + viewport_set_lang(self.handle, c_ext.as_ptr()); + if let Some(w) = &self.window { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); + w.set_title(&format!("{name} - Acord")); + } + } + } + } + + fn save_file(&self) { + self.save_file_as(); + } + + fn save_file_as(&self) { + let dialog = rfd::FileDialog::new() + .add_filter("Markdown", &["md"]) + .add_filter("All Files", &["*"]) + .set_file_name("note.md"); + if let Some(path) = dialog.save_file() { + let text_ptr = viewport_get_text(self.handle); + if !text_ptr.is_null() { + let text = unsafe { std::ffi::CStr::from_ptr(text_ptr) } + .to_string_lossy() + .into_owned(); + viewport_free_string(text_ptr); + let _ = std::fs::write(&path, text); + } + } + } + + fn new_note(&mut self) { + let empty = CString::new("").unwrap(); + viewport_set_text(self.handle, empty.as_ptr()); + if let Some(w) = &self.window { + w.set_title("Acord"); + } + } + + fn winit_button(button: MouseButton) -> u8 { + match button { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + MouseButton::Other(n) => n as u8, + _ => 0, + } + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { return; } + + let attrs = WindowAttributes::default() + .with_title("Acord") + .with_inner_size(LogicalSize::new(1100.0, 750.0)); + let window = event_loop.create_window(attrs).expect("create window"); + self.scale = window.scale_factor() as f32; + + let size = window.inner_size(); + let w = size.width as f32 / self.scale; + let h = size.height as f32 / self.scale; + + // Get raw HWND and pass to viewport. + use raw_window_handle::HasWindowHandle; + let wh = window.window_handle().expect("window handle"); + let raw = wh.as_raw(); + + let hwnd = match raw { + #[cfg(target_os = "windows")] + raw_window_handle::RawWindowHandle::Win32(h) => h.hwnd.get() as *mut c_void, + #[cfg(target_os = "macos")] + raw_window_handle::RawWindowHandle::AppKit(h) => h.ns_view.as_ptr(), + _ => std::ptr::null_mut(), + }; + + self.handle = viewport_create(hwnd, w, h, self.scale); + self.sync_settings(); + + // Set up native menu bar. + let app_menu = AppMenu::new(); + #[cfg(target_os = "windows")] + { + use raw_window_handle::HasWindowHandle; + if let Ok(wh) = window.window_handle() { + if let raw_window_handle::RawWindowHandle::Win32(h) = wh.as_raw() { + use winapi::shared::windef::HWND; + app_menu.menu.init_for_hwnd(h.hwnd.get() as HWND).ok(); + } + } + } + self.menu = Some(app_menu); + self.window = Some(window); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + if self.handle.is_null() { return; } + + match event { + WindowEvent::CloseRequested => { + if !self.handle.is_null() { + viewport_destroy(self.handle); + self.handle = std::ptr::null_mut(); + } + event_loop.exit(); + } + + WindowEvent::Resized(size) => { + let w = size.width as f32 / self.scale; + let h = size.height as f32 / self.scale; + viewport_resize(self.handle, w, h, self.scale); + } + + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + self.scale = scale_factor as f32; + if let Some(win) = &self.window { + let size = win.inner_size(); + let w = size.width as f32 / self.scale; + let h = size.height as f32 / self.scale; + viewport_resize(self.handle, w, h, self.scale); + } + } + + WindowEvent::RedrawRequested => { + viewport_render(self.handle); + } + + WindowEvent::CursorMoved { position, .. } => { + self.cursor_pos = position; + let x = position.x as f32 / self.scale; + let y = position.y as f32 / self.scale; + acord_viewport::viewport_mouse_event( + self.handle, x, y, 255, false, + ); + } + + WindowEvent::MouseInput { state, button, .. } => { + let x = self.cursor_pos.x as f32 / self.scale; + let y = self.cursor_pos.y as f32 / self.scale; + let pressed = state == ElementState::Pressed; + acord_viewport::viewport_mouse_event( + self.handle, x, y, Self::winit_button(button), pressed, + ); + } + + WindowEvent::MouseWheel { delta, .. } => { + let x = self.cursor_pos.x as f32 / self.scale; + let y = self.cursor_pos.y as f32 / self.scale; + let (dx, dy) = match delta { + MouseScrollDelta::LineDelta(dx, dy) => (dx * 20.0, dy * 20.0), + MouseScrollDelta::PixelDelta(d) => (d.x as f32, d.y as f32), + }; + acord_viewport::viewport_scroll_event(self.handle, x, y, dx, -dy); + } + + WindowEvent::KeyboardInput { event, .. } => { + let pressed = event.state == ElementState::Pressed; + let text_str = event.text.as_ref().map(|s| s.to_string()); + let text_c = text_str.as_deref() + .and_then(|s| CString::new(s).ok()); + let text_ptr = text_c.as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(std::ptr::null()); + + let keycode = winit_key_to_code(&event.logical_key); + let modifiers = if let Some(w) = &self.window { + // No direct modifier query on winit 0.30 Window. + // Modifiers come via ModifiersChanged. We track them. + 0u32 + } else { + 0u32 + }; + + acord_viewport::viewport_key_event( + self.handle, keycode, modifiers, pressed, text_ptr, + ); + } + + WindowEvent::ModifiersChanged(mods) => { + if !self.handle.is_null() { + let state = mods.state(); + let h = unsafe { &mut *self.handle }; + use iced_wgpu::core::keyboard; + use iced_wgpu::core::Event; + h.events.push(Event::Keyboard( + keyboard::Event::ModifiersChanged(decode_winit_modifiers(state)), + )); + h.needs_redraw = true; + } + } + + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + // Poll menu events. + while let Some(action) = AppMenu::poll() { + self.dispatch_menu(action, _event_loop); + } + // Request a redraw if the viewport has pending work. + if let Some(w) = &self.window { + if !self.handle.is_null() { + // Always request redraw — viewport_render short-circuits + // internally when idle (needs_redraw == false && no pending + // eval). Requesting unconditionally is simpler than reading + // the handle's state from here, and wgpu PresentMode::Fifo + // throttles to vsync anyway. + w.request_redraw(); + } + } + } +} + +/// Map winit logical keys to the macOS-style keycodes the bridge expects. +/// For Named keys, return the matching keycode. For character keys, the +/// bridge ignores the keycode and uses the text parameter directly, so +/// we return 0 (unmapped). +fn winit_key_to_code(key: &Key) -> u32 { + match key { + Key::Named(n) => match n { + NamedKey::Enter => 36, + NamedKey::Tab => 48, + NamedKey::Backspace => 51, + NamedKey::Escape => 53, + NamedKey::Delete => 117, + NamedKey::ArrowLeft => 123, + NamedKey::ArrowRight => 124, + NamedKey::ArrowDown => 125, + NamedKey::ArrowUp => 126, + NamedKey::Home => 115, + NamedKey::End => 119, + NamedKey::PageUp => 116, + NamedKey::PageDown => 121, + NamedKey::F1 => 122, + NamedKey::F2 => 120, + NamedKey::F3 => 99, + NamedKey::F4 => 118, + NamedKey::F5 => 96, + NamedKey::F6 => 97, + NamedKey::F7 => 98, + NamedKey::F8 => 100, + NamedKey::F9 => 101, + NamedKey::F10 => 109, + NamedKey::F11 => 103, + NamedKey::F12 => 111, + _ => 0, + }, + _ => 0, + } +} + +fn encode_modifiers(state: ModifiersState) -> u32 { + let mut flags = 0u32; + if state.shift_key() { flags |= 1 << 17; } + if state.control_key() { flags |= 1 << 18; } + if state.alt_key() { flags |= 1 << 19; } + if state.super_key() { flags |= 1 << 20; } + flags +} + +fn decode_winit_modifiers(state: ModifiersState) -> iced_wgpu::core::keyboard::Modifiers { + let mut m = iced_wgpu::core::keyboard::Modifiers::empty(); + if state.shift_key() { m |= iced_wgpu::core::keyboard::Modifiers::SHIFT; } + if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::CTRL; } + if state.alt_key() { m |= iced_wgpu::core::keyboard::Modifiers::ALT; } + // On Windows, Ctrl is the action modifier (not Logo/Super). + // Map Ctrl to LOGO so iced's Cmd+C/V/X bindings work via Ctrl on Windows. + if state.control_key() { m |= iced_wgpu::core::keyboard::Modifiers::LOGO; } + m +} diff --git a/windows/src/config.rs b/windows/src/config.rs new file mode 100644 index 0000000..a387da2 --- /dev/null +++ b/windows/src/config.rs @@ -0,0 +1,62 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +pub struct Config { + path: PathBuf, + data: HashMap, +} + +impl Config { + pub fn load() -> Self { + let dir = config_dir(); + std::fs::create_dir_all(&dir).ok(); + let notes = dir.join("notes"); + std::fs::create_dir_all(¬es).ok(); + + let path = dir.join("config.json"); + let data = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + Self { path, data } + } + + pub fn save(&self) { + if let Ok(json) = serde_json::to_string_pretty(&self.data) { + let _ = std::fs::write(&self.path, json); + } + } + + pub fn get(&self, key: &str, default: &str) -> String { + self.data.get(key).cloned().unwrap_or_else(|| default.to_string()) + } + + pub fn set(&mut self, key: &str, value: &str) { + self.data.insert(key.to_string(), value.to_string()); + self.save(); + } + + pub fn theme_mode(&self) -> &str { + self.data.get("themeMode").map(|s| s.as_str()).unwrap_or("auto") + } + + pub fn line_indicator(&self) -> &str { + self.data.get("lineIndicatorMode").map(|s| s.as_str()).unwrap_or("on") + } + + pub fn gutter_rainbow(&self) -> bool { + self.data.get("gutterRainbow").map(|s| s != "false").unwrap_or(true) + } + + pub fn notes_dir(&self) -> PathBuf { + self.data.get("autoSaveDirectory") + .map(PathBuf::from) + .unwrap_or_else(|| config_dir().join("notes")) + } +} + +fn config_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".acord") +} diff --git a/windows/src/main.rs b/windows/src/main.rs new file mode 100644 index 0000000..ae7fe82 --- /dev/null +++ b/windows/src/main.rs @@ -0,0 +1,9 @@ +mod app; +mod config; +mod menu; + +fn main() { + let event_loop = winit::event_loop::EventLoop::new().expect("event loop"); + let mut state = app::App::new(); + event_loop.run_app(&mut state).expect("run"); +} diff --git a/windows/src/menu.rs b/windows/src/menu.rs new file mode 100644 index 0000000..12d2808 --- /dev/null +++ b/windows/src/menu.rs @@ -0,0 +1,106 @@ +use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, accelerator::Accelerator}; +use muda::accelerator::{Code, Modifiers}; + +pub struct AppMenu { + pub menu: Menu, +} + +pub enum MenuAction { + NewNote, + Open, + Save, + SaveAs, + Quit, + Undo, + Redo, + Bold, + Italic, + InsertTable, + Evaluate, + LiveMode, + EditorMode, + ViewMode, + ZoomIn, + ZoomOut, + ZoomReset, + Find, + ExportCrate, +} + +impl AppMenu { + pub fn new() -> Self { + let menu = Menu::new(); + + let file = Submenu::new("File", true); + file.append(&MenuItem::with_id("new", "New Note", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)))).ok(); + file.append(&MenuItem::with_id("open", "Open...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)))).ok(); + file.append(&PredefinedMenuItem::separator()).ok(); + file.append(&MenuItem::with_id("save", "Save", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)))).ok(); + file.append(&MenuItem::with_id("save_as", "Save As...", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)))).ok(); + file.append(&PredefinedMenuItem::separator()).ok(); + file.append(&MenuItem::with_id("export_crate", "Export as Rust Library", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyE)))).ok(); + file.append(&PredefinedMenuItem::separator()).ok(); + file.append(&MenuItem::with_id("quit", "Quit", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyQ)))).ok(); + + let edit = Submenu::new("Edit", true); + edit.append(&MenuItem::with_id("undo", "Undo", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyZ)))).ok(); + edit.append(&MenuItem::with_id("redo", "Redo", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyZ)))).ok(); + edit.append(&PredefinedMenuItem::separator()).ok(); + edit.append(&PredefinedMenuItem::cut(None)).ok(); + edit.append(&PredefinedMenuItem::copy(None)).ok(); + edit.append(&PredefinedMenuItem::paste(None)).ok(); + edit.append(&PredefinedMenuItem::select_all(None)).ok(); + edit.append(&PredefinedMenuItem::separator()).ok(); + edit.append(&MenuItem::with_id("find", "Find...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyF)))).ok(); + edit.append(&PredefinedMenuItem::separator()).ok(); + edit.append(&MenuItem::with_id("bold", "Bold", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyB)))).ok(); + edit.append(&MenuItem::with_id("italic", "Italic", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyI)))).ok(); + edit.append(&MenuItem::with_id("table", "Insert Table", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyT)))).ok(); + + let render = Submenu::new("Render", true); + render.append(&MenuItem::with_id("live", "Live", true, None)).ok(); + render.append(&MenuItem::with_id("editor", "Editor", true, None)).ok(); + render.append(&MenuItem::with_id("view", "View", true, None)).ok(); + render.append(&PredefinedMenuItem::separator()).ok(); + render.append(&MenuItem::with_id("eval", "Evaluate", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Enter)))).ok(); + + let view = Submenu::new("View", true); + view.append(&MenuItem::with_id("zoom_in", "Zoom In", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Equal)))).ok(); + view.append(&MenuItem::with_id("zoom_out", "Zoom Out", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Minus)))).ok(); + view.append(&MenuItem::with_id("zoom_reset", "Reset Zoom", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Digit0)))).ok(); + + menu.append(&file).ok(); + menu.append(&edit).ok(); + menu.append(&render).ok(); + menu.append(&view).ok(); + + Self { menu } + } + + pub fn poll() -> Option { + MenuEvent::receiver().try_recv().ok().and_then(|e| { + match e.id().0.as_str() { + "new" => Some(MenuAction::NewNote), + "open" => Some(MenuAction::Open), + "save" => Some(MenuAction::Save), + "save_as" => Some(MenuAction::SaveAs), + "quit" => Some(MenuAction::Quit), + "undo" => Some(MenuAction::Undo), + "redo" => Some(MenuAction::Redo), + "bold" => Some(MenuAction::Bold), + "italic" => Some(MenuAction::Italic), + "table" => Some(MenuAction::InsertTable), + "eval" => Some(MenuAction::Evaluate), + "live" => Some(MenuAction::LiveMode), + "editor" => Some(MenuAction::EditorMode), + "view" => Some(MenuAction::ViewMode), + "zoom_in" => Some(MenuAction::ZoomIn), + "zoom_out" => Some(MenuAction::ZoomOut), + "zoom_reset" => Some(MenuAction::ZoomReset), + "find" => Some(MenuAction::Find), + "export_crate" => Some(MenuAction::ExportCrate), + _ => None, + } + }) + } +}