diff --git a/.cargo/baseview b/.cargo/baseview new file mode 160000 index 0000000..237d323 --- /dev/null +++ b/.cargo/baseview @@ -0,0 +1 @@ +Subproject commit 237d323c729f3aa99476ba3efa50129c5e86cad3 diff --git a/.cargo/egui-baseview b/.cargo/egui-baseview new file mode 160000 index 0000000..c5ad2f4 --- /dev/null +++ b/.cargo/egui-baseview @@ -0,0 +1 @@ +Subproject commit c5ad2f4bd094b100321a804122cf2218b43370da diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fcfa88..a44e992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,9 @@ jobs: if: runner.os != 'Windows' run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }} + - name: Bundle CLAP plugin + run: cargo xtask bundle cagire-clap --release --target ${{ matrix.target }} + - name: Zip macOS app bundle if: runner.os == 'macOS' run: | @@ -122,6 +125,18 @@ jobs: name: ${{ matrix.artifact }}-desktop path: target/${{ matrix.target }}/release/cagire-desktop.exe + - name: Upload CLAP artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }}-clap + path: target/bundled/cagire-clap.clap + + - name: Upload VST3 artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }}-vst3 + path: target/bundled/cagire-clap.vst3 + universal-macos: needs: build if: startsWith(github.ref, 'refs/tags/v') @@ -159,6 +174,34 @@ jobs: lipo -info Cagire.app/Contents/MacOS/cagire-desktop zip -r Cagire.app.zip Cagire.app + - name: Create universal CLAP plugin + run: | + mkdir -p cagire-clap.clap/Contents/MacOS + cp artifacts/cagire-macos-aarch64-clap/cagire-clap.clap/Contents/Info.plist \ + cagire-clap.clap/Contents/ 2>/dev/null || true + cp artifacts/cagire-macos-aarch64-clap/cagire-clap.clap/Contents/PkgInfo \ + cagire-clap.clap/Contents/ 2>/dev/null || true + lipo -create \ + artifacts/cagire-macos-x86_64-clap/cagire-clap.clap/Contents/MacOS/cagire-clap \ + artifacts/cagire-macos-aarch64-clap/cagire-clap.clap/Contents/MacOS/cagire-clap \ + -output cagire-clap.clap/Contents/MacOS/cagire-clap + lipo -info cagire-clap.clap/Contents/MacOS/cagire-clap + + - name: Create universal VST3 plugin + run: | + mkdir -p cagire-clap.vst3/Contents/MacOS + cp -R artifacts/cagire-macos-aarch64-vst3/cagire-clap.vst3/Contents/Info.plist \ + cagire-clap.vst3/Contents/ 2>/dev/null || true + cp artifacts/cagire-macos-aarch64-vst3/cagire-clap.vst3/Contents/PkgInfo \ + cagire-clap.vst3/Contents/ 2>/dev/null || true + cp -R artifacts/cagire-macos-aarch64-vst3/cagire-clap.vst3/Contents/Resources \ + cagire-clap.vst3/Contents/ 2>/dev/null || true + lipo -create \ + artifacts/cagire-macos-x86_64-vst3/cagire-clap.vst3/Contents/MacOS/cagire-clap \ + artifacts/cagire-macos-aarch64-vst3/cagire-clap.vst3/Contents/MacOS/cagire-clap \ + -output cagire-clap.vst3/Contents/MacOS/cagire-clap + lipo -info cagire-clap.vst3/Contents/MacOS/cagire-clap + - name: Build .pkg installer run: | VERSION="${GITHUB_REF_NAME#v}" @@ -184,6 +227,18 @@ jobs: name: cagire-macos-universal-desktop path: Cagire.app.zip + - name: Upload universal CLAP plugin + uses: actions/upload-artifact@v4 + with: + name: cagire-macos-universal-clap + path: cagire-clap.clap + + - name: Upload universal VST3 plugin + uses: actions/upload-artifact@v4 + with: + name: cagire-macos-universal-vst3 + path: cagire-clap.vst3 + - name: Upload .pkg installer uses: actions/upload-artifact@v4 with: @@ -215,6 +270,16 @@ jobs: cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip" elif [[ "$name" == "cagire-macos-universal" ]]; then cp "$dir/cagire" "release/cagire-macos-universal" + elif [[ "$name" == "cagire-macos-universal-clap" ]]; then + cd "$dir" && zip -r "../../release/cagire-macos-universal-clap.zip" cagire-clap.clap && cd ../.. + elif [[ "$name" == "cagire-macos-universal-vst3" ]]; then + cd "$dir" && zip -r "../../release/cagire-macos-universal-vst3.zip" cagire-clap.vst3 && cd ../.. + elif [[ "$name" == *-clap ]]; then + base="${name%-clap}" + cd "$dir" && zip -r "../../release/${base}-clap.zip" cagire-clap.clap && cd ../.. + elif [[ "$name" == *-vst3 ]]; then + base="${name%-vst3}" + cd "$dir" && zip -r "../../release/${base}-vst3.zip" cagire-clap.vst3 && cd ../.. elif [[ "$name" == *-desktop ]]; then base="${name%-desktop}" if ls "$dir"/*.deb 1>/dev/null 2>&1; then diff --git a/.gitignore b/.gitignore index 646315b..80bdebb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ Cargo.lock *.prof .DS_Store +# Cargo config +.cargo/config.toml + # Claude .claude/ CLAUDE.md diff --git a/Cargo.toml b/Cargo.toml index c2b6792..7ac8363 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"] +members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "crates/clap", "crates/baseview", "crates/egui-baseview", "xtask"] [workspace.package] version = "0.0.9" @@ -92,6 +92,12 @@ codegen-units = 1 panic = "abort" strip = true +[patch."https://github.com/BillyDM/egui-baseview.git"] +egui-baseview = { path = "crates/egui-baseview" } + +[patch."https://github.com/RustAudio/baseview.git"] +baseview = { path = "crates/baseview" } + [package.metadata.bundle.bin.cagire-desktop] name = "Cagire" identifier = "com.sova.cagire" diff --git a/crates/baseview/Cargo.toml b/crates/baseview/Cargo.toml new file mode 100644 index 0000000..d4f107a --- /dev/null +++ b/crates/baseview/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "baseview" +version = "0.1.0" +edition = "2018" +license = "MIT OR Apache-2.0" + +[features] +default = [] +opengl = ["uuid", "x11/glx"] + +[dependencies] +keyboard-types = { version = "0.6.1", default-features = false } +raw-window-handle = "0.5" + +[target.'cfg(target_os="linux")'.dependencies] +x11rb = { version = "0.13.0", features = ["cursor", "resource_manager", "allow-unsafe-code"] } +x11 = { version = "2.21", features = ["xlib", "xlib_xcb"] } +nix = "0.22.0" + +[target.'cfg(target_os="windows")'.dependencies] +winapi = { version = "0.3.8", features = ["libloaderapi", "winuser", "windef", "minwindef", "guiddef", "combaseapi", "wingdi", "errhandlingapi", "ole2", "oleidl", "shellapi", "winerror"] } +uuid = { version = "0.8", features = ["v4"], optional = true } + +[target.'cfg(target_os="macos")'.dependencies] +cocoa = "0.24.0" +core-foundation = "0.9.1" +objc = "0.2.7" +uuid = { version = "0.8", features = ["v4"] } + +[lints.clippy] +missing-safety-doc = "allow" + +[lints.rust] +unexpected_cfgs = "allow" diff --git a/crates/baseview/LICENSE-APACHE b/crates/baseview/LICENSE-APACHE new file mode 100644 index 0000000..a7e77cb --- /dev/null +++ b/crates/baseview/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/crates/baseview/LICENSE-MIT b/crates/baseview/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/crates/baseview/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/crates/baseview/src/clipboard.rs b/crates/baseview/src/clipboard.rs new file mode 100644 index 0000000..c4f7bc4 --- /dev/null +++ b/crates/baseview/src/clipboard.rs @@ -0,0 +1,10 @@ +#[cfg(target_os = "macos")] +use crate::macos as platform; +#[cfg(target_os = "windows")] +use crate::win as platform; +#[cfg(target_os = "linux")] +use crate::x11 as platform; + +pub fn copy_to_clipboard(data: &str) { + platform::copy_to_clipboard(data) +} diff --git a/crates/baseview/src/event.rs b/crates/baseview/src/event.rs new file mode 100644 index 0000000..64431a0 --- /dev/null +++ b/crates/baseview/src/event.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; + +use keyboard_types::{KeyboardEvent, Modifiers}; + +use crate::{Point, WindowInfo}; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum MouseButton { + Left, + Middle, + Right, + Back, + Forward, + Other(u8), +} + +/// A scroll movement. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ScrollDelta { + /// A line-based scroll movement + Lines { + /// The number of horizontal lines scrolled + x: f32, + + /// The number of vertical lines scrolled + y: f32, + }, + /// A pixel-based scroll movement + Pixels { + /// The number of horizontal pixels scrolled + x: f32, + /// The number of vertical pixels scrolled + y: f32, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MouseEvent { + /// The mouse cursor was moved + CursorMoved { + /// The logical coordinates of the mouse position + position: Point, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + }, + + /// A mouse button was pressed. + ButtonPressed { + /// The button that was pressed. + button: MouseButton, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + }, + + /// A mouse button was released. + ButtonReleased { + /// The button that was released. + button: MouseButton, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + }, + + /// The mouse wheel was scrolled. + WheelScrolled { + /// How much was scrolled, in factional lines. + delta: ScrollDelta, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + }, + + /// The mouse cursor entered the window. + /// + /// May not be available on all platforms. + CursorEntered, + + /// The mouse cursor left the window. + /// + /// May not be available on all platforms. + CursorLeft, + + DragEntered { + /// The logical coordinates of the mouse position + position: Point, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + /// Data being dragged + data: DropData, + }, + + DragMoved { + /// The logical coordinates of the mouse position + position: Point, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + /// Data being dragged + data: DropData, + }, + + DragLeft, + + DragDropped { + /// The logical coordinates of the mouse position + position: Point, + /// The modifiers that were held down just before the event. + modifiers: Modifiers, + /// Data being dragged + data: DropData, + }, +} + +#[derive(Debug, Clone)] +pub enum WindowEvent { + Resized(WindowInfo), + Focused, + Unfocused, + WillClose, +} + +#[derive(Debug, Clone)] +pub enum Event { + Mouse(MouseEvent), + Keyboard(KeyboardEvent), + Window(WindowEvent), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DropEffect { + Copy, + Move, + Link, + Scroll, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DropData { + None, + Files(Vec), +} + +/// Return value for [WindowHandler::on_event](`crate::WindowHandler::on_event()`), +/// indicating whether the event was handled by your window or should be passed +/// back to the platform. +/// +/// For most event types, this value won't have any effect. This is the case +/// when there is no clear meaning of passing back the event to the platform, +/// or it isn't obviously useful. Currently, only [`Event::Keyboard`] variants +/// are supported. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EventStatus { + /// Event was handled by your window and will not be sent back to the + /// platform for further processing. + Captured, + /// Event was **not** handled by your window, so pass it back to the + /// platform. For parented windows, this usually means that the parent + /// window will receive the event. This is useful for cases such as using + /// DAW functionality for playing piano keys with the keyboard while a + /// plugin window is in focus. + Ignored, + /// We are prepared to handle the data in the drag and dropping will + /// result in [DropEffect] + AcceptDrop(DropEffect), +} diff --git a/crates/baseview/src/gl/macos.rs b/crates/baseview/src/gl/macos.rs new file mode 100644 index 0000000..f11fb1f --- /dev/null +++ b/crates/baseview/src/gl/macos.rs @@ -0,0 +1,154 @@ +// This is required because the objc crate is causing a lot of warnings: https://github.com/SSheldon/rust-objc/issues/125 +// Eventually we should migrate to the objc2 crate and remove this. +#![allow(unexpected_cfgs)] + +use std::ffi::c_void; +use std::str::FromStr; + +use raw_window_handle::RawWindowHandle; + +use cocoa::appkit::{ + NSOpenGLContext, NSOpenGLContextParameter, NSOpenGLPFAAccelerated, NSOpenGLPFAAlphaSize, + NSOpenGLPFAColorSize, NSOpenGLPFADepthSize, NSOpenGLPFADoubleBuffer, NSOpenGLPFAMultisample, + NSOpenGLPFAOpenGLProfile, NSOpenGLPFASampleBuffers, NSOpenGLPFASamples, NSOpenGLPFAStencilSize, + NSOpenGLPixelFormat, NSOpenGLProfileVersion3_2Core, NSOpenGLProfileVersion4_1Core, + NSOpenGLProfileVersionLegacy, NSOpenGLView, NSView, +}; +use cocoa::base::{id, nil, YES}; +use cocoa::foundation::NSSize; + +use core_foundation::base::TCFType; +use core_foundation::bundle::{CFBundleGetBundleWithIdentifier, CFBundleGetFunctionPointerForName}; +use core_foundation::string::CFString; + +use objc::{msg_send, sel, sel_impl}; + +use super::{GlConfig, GlError, Profile}; + +pub type CreationFailedError = (); +pub struct GlContext { + view: id, + context: id, +} + +impl GlContext { + pub unsafe fn create(parent: &RawWindowHandle, config: GlConfig) -> Result { + let handle = if let RawWindowHandle::AppKit(handle) = parent { + handle + } else { + return Err(GlError::InvalidWindowHandle); + }; + + if handle.ns_view.is_null() { + return Err(GlError::InvalidWindowHandle); + } + + let parent_view = handle.ns_view as id; + + let version = if config.version < (3, 2) && config.profile == Profile::Compatibility { + NSOpenGLProfileVersionLegacy + } else if config.version == (3, 2) && config.profile == Profile::Core { + NSOpenGLProfileVersion3_2Core + } else if config.version > (3, 2) && config.profile == Profile::Core { + NSOpenGLProfileVersion4_1Core + } else { + return Err(GlError::VersionNotSupported); + }; + + #[rustfmt::skip] + let mut attrs = vec![ + NSOpenGLPFAOpenGLProfile as u32, version as u32, + NSOpenGLPFAColorSize as u32, (config.red_bits + config.blue_bits + config.green_bits) as u32, + NSOpenGLPFAAlphaSize as u32, config.alpha_bits as u32, + NSOpenGLPFADepthSize as u32, config.depth_bits as u32, + NSOpenGLPFAStencilSize as u32, config.stencil_bits as u32, + NSOpenGLPFAAccelerated as u32, + ]; + + if let Some(samples) = config.samples { + #[rustfmt::skip] + attrs.extend_from_slice(&[ + NSOpenGLPFAMultisample as u32, + NSOpenGLPFASampleBuffers as u32, 1, + NSOpenGLPFASamples as u32, samples as u32, + ]); + } + + if config.double_buffer { + attrs.push(NSOpenGLPFADoubleBuffer as u32); + } + + attrs.push(0); + + let pixel_format = NSOpenGLPixelFormat::alloc(nil).initWithAttributes_(&attrs); + + if pixel_format == nil { + return Err(GlError::CreationFailed(())); + } + + let view = + NSOpenGLView::alloc(nil).initWithFrame_pixelFormat_(parent_view.frame(), pixel_format); + + if view == nil { + return Err(GlError::CreationFailed(())); + } + + view.setWantsBestResolutionOpenGLSurface_(YES); + + NSOpenGLView::display_(view); + parent_view.addSubview_(view); + + let context: id = msg_send![view, openGLContext]; + let () = msg_send![context, retain]; + + context.setValues_forParameter_( + &(config.vsync as i32), + NSOpenGLContextParameter::NSOpenGLCPSwapInterval, + ); + + let () = msg_send![pixel_format, release]; + + Ok(GlContext { view, context }) + } + + pub unsafe fn make_current(&self) { + self.context.makeCurrentContext(); + } + + pub unsafe fn make_not_current(&self) { + NSOpenGLContext::clearCurrentContext(self.context); + } + + pub fn get_proc_address(&self, symbol: &str) -> *const c_void { + let symbol_name = CFString::from_str(symbol).unwrap(); + let framework_name = CFString::from_str("com.apple.opengl").unwrap(); + let framework = + unsafe { CFBundleGetBundleWithIdentifier(framework_name.as_concrete_TypeRef()) }; + + unsafe { CFBundleGetFunctionPointerForName(framework, symbol_name.as_concrete_TypeRef()) } + } + + pub fn swap_buffers(&self) { + unsafe { + self.context.flushBuffer(); + let () = msg_send![self.view, setNeedsDisplay: YES]; + } + } + + /// On macOS the `NSOpenGLView` needs to be resized separtely from our main view. + pub(crate) fn resize(&self, size: NSSize) { + unsafe { NSView::setFrameSize(self.view, size) }; + unsafe { + let _: () = msg_send![self.view, setNeedsDisplay: YES]; + } + } +} + +impl Drop for GlContext { + fn drop(&mut self) { + unsafe { + let () = msg_send![self.context, release]; + let () = msg_send![self.view, release]; + } + } +} diff --git a/crates/baseview/src/gl/mod.rs b/crates/baseview/src/gl/mod.rs new file mode 100644 index 0000000..488cfd7 --- /dev/null +++ b/crates/baseview/src/gl/mod.rs @@ -0,0 +1,115 @@ +use std::ffi::c_void; +use std::marker::PhantomData; + +// On X11 creating the context is a two step process +#[cfg(not(target_os = "linux"))] +use raw_window_handle::RawWindowHandle; + +#[cfg(target_os = "windows")] +mod win; +#[cfg(target_os = "windows")] +use win as platform; + +// We need to use this directly within the X11 window creation to negotiate the correct visual +#[cfg(target_os = "linux")] +pub(crate) mod x11; +#[cfg(target_os = "linux")] +pub(crate) use self::x11 as platform; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; + +#[derive(Clone, Debug)] +pub struct GlConfig { + pub version: (u8, u8), + pub profile: Profile, + pub red_bits: u8, + pub blue_bits: u8, + pub green_bits: u8, + pub alpha_bits: u8, + pub depth_bits: u8, + pub stencil_bits: u8, + pub samples: Option, + pub srgb: bool, + pub double_buffer: bool, + pub vsync: bool, +} + +impl Default for GlConfig { + fn default() -> Self { + GlConfig { + version: (3, 2), + profile: Profile::Core, + red_bits: 8, + blue_bits: 8, + green_bits: 8, + alpha_bits: 8, + depth_bits: 24, + stencil_bits: 8, + samples: None, + srgb: true, + double_buffer: true, + vsync: false, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Profile { + Compatibility, + Core, +} + +#[derive(Debug)] +pub enum GlError { + InvalidWindowHandle, + VersionNotSupported, + CreationFailed(platform::CreationFailedError), +} + +pub struct GlContext { + context: platform::GlContext, + phantom: PhantomData<*mut ()>, +} + +impl GlContext { + #[cfg(not(target_os = "linux"))] + pub(crate) unsafe fn create( + parent: &RawWindowHandle, config: GlConfig, + ) -> Result { + platform::GlContext::create(parent, config) + .map(|context| GlContext { context, phantom: PhantomData }) + } + + /// The X11 version needs to be set up in a different way compared to the Windows and macOS + /// versions. So the platform-specific versions should be used to construct the context within + /// baseview, and then this object can be passed to the user. + #[cfg(target_os = "linux")] + pub(crate) fn new(context: platform::GlContext) -> GlContext { + GlContext { context, phantom: PhantomData } + } + + pub unsafe fn make_current(&self) { + self.context.make_current(); + } + + pub unsafe fn make_not_current(&self) { + self.context.make_not_current(); + } + + pub fn get_proc_address(&self, symbol: &str) -> *const c_void { + self.context.get_proc_address(symbol) + } + + pub fn swap_buffers(&self) { + self.context.swap_buffers(); + } + + /// On macOS the `NSOpenGLView` needs to be resized separtely from our main view. + #[cfg(target_os = "macos")] + pub(crate) fn resize(&self, size: cocoa::foundation::NSSize) { + self.context.resize(size); + } +} diff --git a/crates/baseview/src/gl/win.rs b/crates/baseview/src/gl/win.rs new file mode 100644 index 0000000..097eb09 --- /dev/null +++ b/crates/baseview/src/gl/win.rs @@ -0,0 +1,308 @@ +use std::ffi::{c_void, CString, OsStr}; +use std::os::windows::ffi::OsStrExt; + +use raw_window_handle::RawWindowHandle; + +use winapi::shared::minwindef::{HINSTANCE, HMODULE}; +use winapi::shared::ntdef::WCHAR; +use winapi::shared::windef::{HDC, HGLRC, HWND}; +use winapi::um::libloaderapi::{FreeLibrary, GetProcAddress, LoadLibraryA}; +use winapi::um::wingdi::{ + wglCreateContext, wglDeleteContext, wglGetProcAddress, wglMakeCurrent, ChoosePixelFormat, + DescribePixelFormat, SetPixelFormat, SwapBuffers, PFD_DOUBLEBUFFER, PFD_DRAW_TO_WINDOW, + PFD_MAIN_PLANE, PFD_SUPPORT_OPENGL, PFD_TYPE_RGBA, PIXELFORMATDESCRIPTOR, +}; +use winapi::um::winnt::IMAGE_DOS_HEADER; +use winapi::um::winuser::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, GetDC, RegisterClassW, ReleaseDC, + UnregisterClassW, CS_OWNDC, CW_USEDEFAULT, WNDCLASSW, +}; + +use super::{GlConfig, GlError, Profile}; + +// See https://www.khronos.org/registry/OpenGL/extensions/ARB/WGL_ARB_create_context.txt + +type WglCreateContextAttribsARB = extern "system" fn(HDC, HGLRC, *const i32) -> HGLRC; + +const WGL_CONTEXT_MAJOR_VERSION_ARB: i32 = 0x2091; +const WGL_CONTEXT_MINOR_VERSION_ARB: i32 = 0x2092; +const WGL_CONTEXT_PROFILE_MASK_ARB: i32 = 0x9126; + +const WGL_CONTEXT_CORE_PROFILE_BIT_ARB: i32 = 0x00000001; +const WGL_CONTEXT_COMPATIBILITY_PROFILE_BIT_ARB: i32 = 0x00000002; + +// See https://www.khronos.org/registry/OpenGL/extensions/ARB/WGL_ARB_pixel_format.txt + +type WglChoosePixelFormatARB = + extern "system" fn(HDC, *const i32, *const f32, u32, *mut i32, *mut u32) -> i32; + +const WGL_DRAW_TO_WINDOW_ARB: i32 = 0x2001; +const WGL_ACCELERATION_ARB: i32 = 0x2003; +const WGL_SUPPORT_OPENGL_ARB: i32 = 0x2010; +const WGL_DOUBLE_BUFFER_ARB: i32 = 0x2011; +const WGL_PIXEL_TYPE_ARB: i32 = 0x2013; +const WGL_RED_BITS_ARB: i32 = 0x2015; +const WGL_GREEN_BITS_ARB: i32 = 0x2017; +const WGL_BLUE_BITS_ARB: i32 = 0x2019; +const WGL_ALPHA_BITS_ARB: i32 = 0x201B; +const WGL_DEPTH_BITS_ARB: i32 = 0x2022; +const WGL_STENCIL_BITS_ARB: i32 = 0x2023; + +const WGL_FULL_ACCELERATION_ARB: i32 = 0x2027; +const WGL_TYPE_RGBA_ARB: i32 = 0x202B; + +// See https://www.khronos.org/registry/OpenGL/extensions/ARB/ARB_multisample.txt + +const WGL_SAMPLE_BUFFERS_ARB: i32 = 0x2041; +const WGL_SAMPLES_ARB: i32 = 0x2042; + +// See https://www.khronos.org/registry/OpenGL/extensions/ARB/ARB_framebuffer_sRGB.txt + +const WGL_FRAMEBUFFER_SRGB_CAPABLE_ARB: i32 = 0x20A9; + +// See https://www.khronos.org/registry/OpenGL/extensions/EXT/WGL_EXT_swap_control.txt + +type WglSwapIntervalEXT = extern "system" fn(i32) -> i32; + +pub type CreationFailedError = (); +pub struct GlContext { + hwnd: HWND, + hdc: HDC, + hglrc: HGLRC, + gl_library: HMODULE, +} + +extern "C" { + static __ImageBase: IMAGE_DOS_HEADER; +} + +impl GlContext { + pub unsafe fn create(parent: &RawWindowHandle, config: GlConfig) -> Result { + let handle = if let RawWindowHandle::Win32(handle) = parent { + handle + } else { + return Err(GlError::InvalidWindowHandle); + }; + + if handle.hwnd.is_null() { + return Err(GlError::InvalidWindowHandle); + } + + // Create temporary window and context to load function pointers + + let class_name_str = format!("raw-gl-context-window-{}", uuid::Uuid::new_v4().to_simple()); + let mut class_name: Vec = OsStr::new(&class_name_str).encode_wide().collect(); + class_name.push(0); + + let hinstance = &__ImageBase as *const IMAGE_DOS_HEADER as HINSTANCE; + + let wnd_class = WNDCLASSW { + style: CS_OWNDC, + lpfnWndProc: Some(DefWindowProcW), + hInstance: hinstance, + lpszClassName: class_name.as_ptr(), + ..std::mem::zeroed() + }; + + let class = RegisterClassW(&wnd_class); + if class == 0 { + return Err(GlError::CreationFailed(())); + } + + let hwnd_tmp = CreateWindowExW( + 0, + class as *const WCHAR, + [0].as_ptr(), + 0, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + std::ptr::null_mut(), + std::ptr::null_mut(), + hinstance, + std::ptr::null_mut(), + ); + + if hwnd_tmp.is_null() { + return Err(GlError::CreationFailed(())); + } + + let hdc_tmp = GetDC(hwnd_tmp); + + let pfd_tmp = PIXELFORMATDESCRIPTOR { + nSize: std::mem::size_of::() as u16, + nVersion: 1, + dwFlags: PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER, + iPixelType: PFD_TYPE_RGBA, + cColorBits: 32, + cAlphaBits: 8, + cDepthBits: 24, + cStencilBits: 8, + iLayerType: PFD_MAIN_PLANE, + ..std::mem::zeroed() + }; + + SetPixelFormat(hdc_tmp, ChoosePixelFormat(hdc_tmp, &pfd_tmp), &pfd_tmp); + + let hglrc_tmp = wglCreateContext(hdc_tmp); + if hglrc_tmp.is_null() { + ReleaseDC(hwnd_tmp, hdc_tmp); + UnregisterClassW(class as *const WCHAR, hinstance); + DestroyWindow(hwnd_tmp); + return Err(GlError::CreationFailed(())); + } + + wglMakeCurrent(hdc_tmp, hglrc_tmp); + + #[allow(non_snake_case)] + let wglCreateContextAttribsARB: Option = { + let symbol = CString::new("wglCreateContextAttribsARB").unwrap(); + let addr = wglGetProcAddress(symbol.as_ptr()); + if !addr.is_null() { + #[allow(clippy::missing_transmute_annotations)] + Some(std::mem::transmute(addr)) + } else { + None + } + }; + + #[allow(non_snake_case)] + let wglChoosePixelFormatARB: Option = { + let symbol = CString::new("wglChoosePixelFormatARB").unwrap(); + let addr = wglGetProcAddress(symbol.as_ptr()); + if !addr.is_null() { + #[allow(clippy::missing_transmute_annotations)] + Some(std::mem::transmute(addr)) + } else { + None + } + }; + + #[allow(non_snake_case)] + let wglSwapIntervalEXT: Option = { + let symbol = CString::new("wglSwapIntervalEXT").unwrap(); + let addr = wglGetProcAddress(symbol.as_ptr()); + if !addr.is_null() { + #[allow(clippy::missing_transmute_annotations)] + Some(std::mem::transmute(addr)) + } else { + None + } + }; + + wglMakeCurrent(hdc_tmp, std::ptr::null_mut()); + wglDeleteContext(hglrc_tmp); + ReleaseDC(hwnd_tmp, hdc_tmp); + UnregisterClassW(class as *const WCHAR, hinstance); + DestroyWindow(hwnd_tmp); + + // Create actual context + + let hwnd = handle.hwnd as HWND; + + let hdc = GetDC(hwnd); + + #[rustfmt::skip] + let pixel_format_attribs = [ + WGL_DRAW_TO_WINDOW_ARB, 1, + WGL_ACCELERATION_ARB, WGL_FULL_ACCELERATION_ARB, + WGL_SUPPORT_OPENGL_ARB, 1, + WGL_DOUBLE_BUFFER_ARB, config.double_buffer as i32, + WGL_PIXEL_TYPE_ARB, WGL_TYPE_RGBA_ARB, + WGL_RED_BITS_ARB, config.red_bits as i32, + WGL_GREEN_BITS_ARB, config.green_bits as i32, + WGL_BLUE_BITS_ARB, config.blue_bits as i32, + WGL_ALPHA_BITS_ARB, config.alpha_bits as i32, + WGL_DEPTH_BITS_ARB, config.depth_bits as i32, + WGL_STENCIL_BITS_ARB, config.stencil_bits as i32, + WGL_SAMPLE_BUFFERS_ARB, config.samples.is_some() as i32, + WGL_SAMPLES_ARB, config.samples.unwrap_or(0) as i32, + WGL_FRAMEBUFFER_SRGB_CAPABLE_ARB, config.srgb as i32, + 0, + ]; + + let mut pixel_format = 0; + let mut num_formats = 0; + wglChoosePixelFormatARB.unwrap()( + hdc, + pixel_format_attribs.as_ptr(), + std::ptr::null(), + 1, + &mut pixel_format, + &mut num_formats, + ); + + let mut pfd: PIXELFORMATDESCRIPTOR = std::mem::zeroed(); + DescribePixelFormat( + hdc, + pixel_format, + std::mem::size_of::() as u32, + &mut pfd, + ); + SetPixelFormat(hdc, pixel_format, &pfd); + + let profile_mask = match config.profile { + Profile::Core => WGL_CONTEXT_CORE_PROFILE_BIT_ARB, + Profile::Compatibility => WGL_CONTEXT_COMPATIBILITY_PROFILE_BIT_ARB, + }; + + #[rustfmt::skip] + let ctx_attribs = [ + WGL_CONTEXT_MAJOR_VERSION_ARB, config.version.0 as i32, + WGL_CONTEXT_MINOR_VERSION_ARB, config.version.1 as i32, + WGL_CONTEXT_PROFILE_MASK_ARB, profile_mask, + 0 + ]; + + let hglrc = + wglCreateContextAttribsARB.unwrap()(hdc, std::ptr::null_mut(), ctx_attribs.as_ptr()); + if hglrc.is_null() { + return Err(GlError::CreationFailed(())); + } + + let gl_library_name = CString::new("opengl32.dll").unwrap(); + let gl_library = LoadLibraryA(gl_library_name.as_ptr()); + + wglMakeCurrent(hdc, hglrc); + wglSwapIntervalEXT.unwrap()(config.vsync as i32); + wglMakeCurrent(hdc, std::ptr::null_mut()); + + Ok(GlContext { hwnd, hdc, hglrc, gl_library }) + } + + pub unsafe fn make_current(&self) { + wglMakeCurrent(self.hdc, self.hglrc); + } + + pub unsafe fn make_not_current(&self) { + wglMakeCurrent(self.hdc, std::ptr::null_mut()); + } + + pub fn get_proc_address(&self, symbol: &str) -> *const c_void { + let symbol = CString::new(symbol).unwrap(); + let addr = unsafe { wglGetProcAddress(symbol.as_ptr()) as *const c_void }; + if !addr.is_null() { + addr + } else { + unsafe { GetProcAddress(self.gl_library, symbol.as_ptr()) as *const c_void } + } + } + + pub fn swap_buffers(&self) { + unsafe { + SwapBuffers(self.hdc); + } + } +} + +impl Drop for GlContext { + fn drop(&mut self) { + unsafe { + wglMakeCurrent(std::ptr::null_mut(), std::ptr::null_mut()); + wglDeleteContext(self.hglrc); + ReleaseDC(self.hwnd, self.hdc); + FreeLibrary(self.gl_library); + } + } +} diff --git a/crates/baseview/src/gl/x11.rs b/crates/baseview/src/gl/x11.rs new file mode 100644 index 0000000..4a4de73 --- /dev/null +++ b/crates/baseview/src/gl/x11.rs @@ -0,0 +1,249 @@ +use std::ffi::{c_void, CString}; +use std::os::raw::{c_int, c_ulong}; + +use x11::glx; +use x11::xlib; + +use super::{GlConfig, GlError, Profile}; + +mod errors; + +#[derive(Debug)] +pub enum CreationFailedError { + InvalidFBConfig, + NoVisual, + GetProcAddressFailed, + MakeCurrentFailed, + ContextCreationFailed, + X11Error(errors::XLibError), +} + +impl From for GlError { + fn from(e: errors::XLibError) -> Self { + GlError::CreationFailed(CreationFailedError::X11Error(e)) + } +} + +// See https://www.khronos.org/registry/OpenGL/extensions/ARB/GLX_ARB_create_context.txt + +type GlXCreateContextAttribsARB = unsafe extern "C" fn( + dpy: *mut xlib::Display, + fbc: glx::GLXFBConfig, + share_context: glx::GLXContext, + direct: xlib::Bool, + attribs: *const c_int, +) -> glx::GLXContext; + +// See https://www.khronos.org/registry/OpenGL/extensions/EXT/EXT_swap_control.txt + +type GlXSwapIntervalEXT = + unsafe extern "C" fn(dpy: *mut xlib::Display, drawable: glx::GLXDrawable, interval: i32); + +// See https://www.khronos.org/registry/OpenGL/extensions/ARB/ARB_framebuffer_sRGB.txt + +const GLX_FRAMEBUFFER_SRGB_CAPABLE_ARB: i32 = 0x20B2; + +fn get_proc_address(symbol: &str) -> *const c_void { + let symbol = CString::new(symbol).unwrap(); + unsafe { glx::glXGetProcAddress(symbol.as_ptr() as *const u8).unwrap() as *const c_void } +} + +pub struct GlContext { + window: c_ulong, + display: *mut xlib::_XDisplay, + context: glx::GLXContext, +} + +/// The frame buffer configuration along with the general OpenGL configuration to somewhat minimize +/// misuse. +pub struct FbConfig { + gl_config: GlConfig, + fb_config: *mut glx::__GLXFBConfigRec, +} + +/// The configuration a window should be created with after calling +/// [GlContext::get_fb_config_and_visual]. +pub struct WindowConfig { + pub depth: u8, + pub visual: u32, +} + +impl GlContext { + /// Creating an OpenGL context under X11 works slightly different. Different OpenGL + /// configurations require different framebuffer configurations, and to be able to use that + /// context with a window the window needs to be created with a matching visual. This means that + /// you need to decide on the framebuffer config before creating the window, ask the X11 server + /// for a matching visual for that framebuffer config, crate the window with that visual, and + /// only then create the OpenGL context. + /// + /// Use [Self::get_fb_config_and_visual] to create both of these things. + pub unsafe fn create( + window: c_ulong, display: *mut xlib::_XDisplay, config: FbConfig, + ) -> Result { + if display.is_null() { + return Err(GlError::InvalidWindowHandle); + } + + errors::XErrorHandler::handle(display, |error_handler| { + #[allow(non_snake_case)] + let glXCreateContextAttribsARB: GlXCreateContextAttribsARB = { + let addr = get_proc_address("glXCreateContextAttribsARB"); + if addr.is_null() { + return Err(GlError::CreationFailed(CreationFailedError::GetProcAddressFailed)); + } else { + #[allow(clippy::missing_transmute_annotations)] + std::mem::transmute(addr) + } + }; + + #[allow(non_snake_case)] + let glXSwapIntervalEXT: GlXSwapIntervalEXT = { + let addr = get_proc_address("glXSwapIntervalEXT"); + if addr.is_null() { + return Err(GlError::CreationFailed(CreationFailedError::GetProcAddressFailed)); + } else { + #[allow(clippy::missing_transmute_annotations)] + std::mem::transmute(addr) + } + }; + + error_handler.check()?; + + let profile_mask = match config.gl_config.profile { + Profile::Core => glx::arb::GLX_CONTEXT_CORE_PROFILE_BIT_ARB, + Profile::Compatibility => glx::arb::GLX_CONTEXT_COMPATIBILITY_PROFILE_BIT_ARB, + }; + + #[rustfmt::skip] + let ctx_attribs = [ + glx::arb::GLX_CONTEXT_MAJOR_VERSION_ARB, config.gl_config.version.0 as i32, + glx::arb::GLX_CONTEXT_MINOR_VERSION_ARB, config.gl_config.version.1 as i32, + glx::arb::GLX_CONTEXT_PROFILE_MASK_ARB, profile_mask, + 0, + ]; + + let context = glXCreateContextAttribsARB( + display, + config.fb_config, + std::ptr::null_mut(), + 1, + ctx_attribs.as_ptr(), + ); + + error_handler.check()?; + + if context.is_null() { + return Err(GlError::CreationFailed(CreationFailedError::ContextCreationFailed)); + } + + let res = glx::glXMakeCurrent(display, window, context); + error_handler.check()?; + if res == 0 { + return Err(GlError::CreationFailed(CreationFailedError::MakeCurrentFailed)); + } + + glXSwapIntervalEXT(display, window, config.gl_config.vsync as i32); + error_handler.check()?; + + if glx::glXMakeCurrent(display, 0, std::ptr::null_mut()) == 0 { + error_handler.check()?; + return Err(GlError::CreationFailed(CreationFailedError::MakeCurrentFailed)); + } + + Ok(GlContext { window, display, context }) + }) + } + + /// Find a matching framebuffer config and window visual for the given OpenGL configuration. + /// This needs to be passed to [Self::create] along with a handle to a window that was created + /// using the visual also returned from this function. + pub unsafe fn get_fb_config_and_visual( + display: *mut xlib::_XDisplay, config: GlConfig, + ) -> Result<(FbConfig, WindowConfig), GlError> { + errors::XErrorHandler::handle(display, |error_handler| { + let screen = xlib::XDefaultScreen(display); + + #[rustfmt::skip] + let fb_attribs = [ + glx::GLX_X_RENDERABLE, 1, + glx::GLX_X_VISUAL_TYPE, glx::GLX_TRUE_COLOR, + glx::GLX_DRAWABLE_TYPE, glx::GLX_WINDOW_BIT, + glx::GLX_RENDER_TYPE, glx::GLX_RGBA_BIT, + glx::GLX_RED_SIZE, config.red_bits as i32, + glx::GLX_GREEN_SIZE, config.green_bits as i32, + glx::GLX_BLUE_SIZE, config.blue_bits as i32, + glx::GLX_ALPHA_SIZE, config.alpha_bits as i32, + glx::GLX_DEPTH_SIZE, config.depth_bits as i32, + glx::GLX_STENCIL_SIZE, config.stencil_bits as i32, + glx::GLX_DOUBLEBUFFER, config.double_buffer as i32, + glx::GLX_SAMPLE_BUFFERS, config.samples.is_some() as i32, + glx::GLX_SAMPLES, config.samples.unwrap_or(0) as i32, + GLX_FRAMEBUFFER_SRGB_CAPABLE_ARB, config.srgb as i32, + 0, + ]; + + let mut n_configs = 0; + let fb_config = + glx::glXChooseFBConfig(display, screen, fb_attribs.as_ptr(), &mut n_configs); + + error_handler.check()?; + if n_configs <= 0 || fb_config.is_null() { + return Err(GlError::CreationFailed(CreationFailedError::InvalidFBConfig)); + } + + // Now that we have a matching framebuffer config, we need to know which visual matches + // thsi config so the window is compatible with the OpenGL context we're about to create + let fb_config = *fb_config; + let visual = glx::glXGetVisualFromFBConfig(display, fb_config); + if visual.is_null() { + return Err(GlError::CreationFailed(CreationFailedError::NoVisual)); + } + + Ok(( + FbConfig { fb_config, gl_config: config }, + WindowConfig { depth: (*visual).depth as u8, visual: (*visual).visualid as u32 }, + )) + }) + } + + pub unsafe fn make_current(&self) { + errors::XErrorHandler::handle(self.display, |error_handler| { + let res = glx::glXMakeCurrent(self.display, self.window, self.context); + error_handler.check().unwrap(); + if res == 0 { + panic!("make_current failed") + } + }) + } + + pub unsafe fn make_not_current(&self) { + errors::XErrorHandler::handle(self.display, |error_handler| { + let res = glx::glXMakeCurrent(self.display, 0, std::ptr::null_mut()); + error_handler.check().unwrap(); + if res == 0 { + panic!("make_not_current failed") + } + }) + } + + pub fn get_proc_address(&self, symbol: &str) -> *const c_void { + get_proc_address(symbol) + } + + pub fn swap_buffers(&self) { + unsafe { + errors::XErrorHandler::handle(self.display, |error_handler| { + glx::glXSwapBuffers(self.display, self.window); + error_handler.check().unwrap(); + }) + } + } +} + +impl Drop for GlContext { + fn drop(&mut self) { + unsafe { + glx::glXDestroyContext(self.display, self.context); + } + } +} diff --git a/crates/baseview/src/gl/x11/errors.rs b/crates/baseview/src/gl/x11/errors.rs new file mode 100644 index 0000000..184a625 --- /dev/null +++ b/crates/baseview/src/gl/x11/errors.rs @@ -0,0 +1,166 @@ +use std::ffi::CStr; +use std::fmt::{Debug, Display, Formatter}; +use x11::xlib; + +use std::cell::RefCell; +use std::error::Error; +use std::os::raw::{c_int, c_uchar, c_ulong}; +use std::panic::AssertUnwindSafe; + +thread_local! { + /// Used as part of [`XErrorHandler::handle()`]. When an X11 error occurs during this function, + /// the error gets copied to this RefCell after which the program is allowed to resume. The + /// error can then be converted to a regular Rust Result value afterward. + static CURRENT_X11_ERROR: RefCell> = const { RefCell::new(None) }; +} + +/// A helper struct for safe X11 error handling +pub struct XErrorHandler<'a> { + display: *mut xlib::Display, + error: &'a RefCell>, +} + +impl<'a> XErrorHandler<'a> { + /// Syncs and checks if any previous X11 calls from the given display returned an error + pub fn check(&mut self) -> Result<(), XLibError> { + // Flush all possible previous errors + unsafe { + xlib::XSync(self.display, 0); + } + let error = self.error.borrow_mut().take(); + + match error { + None => Ok(()), + Some(inner) => Err(inner), + } + } + + /// Sets up a temporary X11 error handler for the duration of the given closure, and allows + /// that closure to check on the latest X11 error at any time. + /// + /// # Safety + /// + /// The given display pointer *must* be and remain valid for the duration of this function, as + /// well as for the duration of the given `handler` closure. + pub unsafe fn handle T>( + display: *mut xlib::Display, handler: F, + ) -> T { + /// # Safety + /// The given display and error pointers *must* be valid for the duration of this function. + unsafe extern "C" fn error_handler( + _dpy: *mut xlib::Display, err: *mut xlib::XErrorEvent, + ) -> i32 { + // SAFETY: the error pointer should be safe to access + let err = &*err; + + CURRENT_X11_ERROR.with(|error| { + let mut error = error.borrow_mut(); + match error.as_mut() { + // If multiple errors occur, keep the first one since that's likely going to be the + // cause of the other errors + Some(_) => 1, + None => { + *error = Some(XLibError::from_event(err)); + 0 + } + } + }) + } + + // Flush all possible previous errors + unsafe { + xlib::XSync(display, 0); + } + + CURRENT_X11_ERROR.with(|error| { + // Make sure to clear any errors from the last call to this function + { + *error.borrow_mut() = None; + } + + let old_handler = unsafe { xlib::XSetErrorHandler(Some(error_handler)) }; + let panic_result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let mut h = XErrorHandler { display, error }; + handler(&mut h) + })); + // Whatever happened, restore old error handler + unsafe { xlib::XSetErrorHandler(old_handler) }; + + match panic_result { + Ok(v) => v, + Err(e) => std::panic::resume_unwind(e), + } + }) + } +} + +pub struct XLibError { + type_: c_int, + resourceid: xlib::XID, + serial: c_ulong, + error_code: c_uchar, + request_code: c_uchar, + minor_code: c_uchar, + + display_name: Box, +} + +impl XLibError { + /// # Safety + /// The display pointer inside error must be valid for the duration of this call + unsafe fn from_event(error: &xlib::XErrorEvent) -> Self { + Self { + type_: error.type_, + resourceid: error.resourceid, + serial: error.serial, + + error_code: error.error_code, + request_code: error.request_code, + minor_code: error.minor_code, + + display_name: Self::get_display_name(error), + } + } + + /// # Safety + /// The display pointer inside error must be valid for the duration of this call + unsafe fn get_display_name(error: &xlib::XErrorEvent) -> Box { + let mut buf = [0; 255]; + unsafe { + xlib::XGetErrorText( + error.display, + error.error_code.into(), + buf.as_mut_ptr().cast(), + (buf.len() - 1) as i32, + ); + } + + *buf.last_mut().unwrap() = 0; + // SAFETY: whatever XGetErrorText did or not, we guaranteed there is a nul byte at the end of the buffer + let cstr = unsafe { CStr::from_ptr(buf.as_mut_ptr().cast()) }; + + cstr.to_string_lossy().into() + } +} + +impl Debug for XLibError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("XLibError") + .field("error_code", &self.error_code) + .field("error_message", &self.display_name) + .field("minor_code", &self.minor_code) + .field("request_code", &self.request_code) + .field("type", &self.type_) + .field("resource_id", &self.resourceid) + .field("serial", &self.serial) + .finish() + } +} + +impl Display for XLibError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "XLib error: {} (error code {})", &self.display_name, self.error_code) + } +} + +impl Error for XLibError {} diff --git a/crates/baseview/src/keyboard.rs b/crates/baseview/src/keyboard.rs new file mode 100644 index 0000000..c56abd3 --- /dev/null +++ b/crates/baseview/src/keyboard.rs @@ -0,0 +1,55 @@ +// Copyright 2020 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Baseview modifications to druid code: +// - only keep code_to_location function + +//! Keyboard types. + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use keyboard_types::{Code, Location}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +/// Map key code to location. +/// +/// The logic for this is adapted from InitKeyEvent in TextInputHandler (in the Mozilla +/// mac port). +/// +/// Note: in the original, this is based on kVK constants, but since we don't have those +/// readily available, we use the mapping to code (which should be effectively lossless). +pub fn code_to_location(code: Code) -> Location { + match code { + Code::MetaLeft | Code::ShiftLeft | Code::AltLeft | Code::ControlLeft => Location::Left, + Code::MetaRight | Code::ShiftRight | Code::AltRight | Code::ControlRight => Location::Right, + Code::Numpad0 + | Code::Numpad1 + | Code::Numpad2 + | Code::Numpad3 + | Code::Numpad4 + | Code::Numpad5 + | Code::Numpad6 + | Code::Numpad7 + | Code::Numpad8 + | Code::Numpad9 + | Code::NumpadAdd + | Code::NumpadComma + | Code::NumpadDecimal + | Code::NumpadDivide + | Code::NumpadEnter + | Code::NumpadEqual + | Code::NumpadMultiply + | Code::NumpadSubtract => Location::Numpad, + _ => Location::Standard, + } +} diff --git a/crates/baseview/src/lib.rs b/crates/baseview/src/lib.rs new file mode 100644 index 0000000..54d57dd --- /dev/null +++ b/crates/baseview/src/lib.rs @@ -0,0 +1,24 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod win; +#[cfg(target_os = "linux")] +mod x11; + +mod clipboard; +mod event; +mod keyboard; +mod mouse_cursor; +mod window; +mod window_info; +mod window_open_options; + +#[cfg(feature = "opengl")] +pub mod gl; + +pub use clipboard::*; +pub use event::*; +pub use mouse_cursor::MouseCursor; +pub use window::*; +pub use window_info::*; +pub use window_open_options::*; diff --git a/crates/baseview/src/macos/keyboard.rs b/crates/baseview/src/macos/keyboard.rs new file mode 100644 index 0000000..18f3bfe --- /dev/null +++ b/crates/baseview/src/macos/keyboard.rs @@ -0,0 +1,357 @@ +// Copyright 2020 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Baseview modifications to druid code: +// - move from_nsstring function to this file +// - update imports, paths etc + +//! Conversion of platform keyboard event into cross-platform event. + +use std::cell::Cell; + +use cocoa::appkit::{NSEvent, NSEventModifierFlags, NSEventType}; +use cocoa::base::id; +use cocoa::foundation::NSString; +use keyboard_types::{Code, Key, KeyState, KeyboardEvent, Modifiers}; +use objc::{msg_send, sel, sel_impl}; + +use crate::keyboard::code_to_location; + +pub(crate) fn from_nsstring(s: id) -> String { + unsafe { + let slice = std::slice::from_raw_parts(s.UTF8String() as *const _, s.len()); + let result = std::str::from_utf8_unchecked(slice); + result.into() + } +} + +/// State for processing of keyboard events. +/// +/// This needs to be stateful for proper processing of dead keys. The current +/// implementation is somewhat primitive and is not based on IME; in the future +/// when IME is implemented, it will need to be redone somewhat, letting the IME +/// be the authoritative source of truth for Unicode string values of keys. +/// +/// Most of the logic in this module is adapted from Mozilla, and in particular +/// TextInputHandler.mm. +pub(crate) struct KeyboardState { + last_mods: Cell, +} + +/// Convert a macOS platform key code (keyCode field of NSEvent). +/// +/// The primary source for this mapping is: +/// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values +/// +/// It should also match up with CODE_MAP_MAC bindings in +/// NativeKeyToDOMCodeName.h. +fn key_code_to_code(key_code: u16) -> Code { + match key_code { + 0x00 => Code::KeyA, + 0x01 => Code::KeyS, + 0x02 => Code::KeyD, + 0x03 => Code::KeyF, + 0x04 => Code::KeyH, + 0x05 => Code::KeyG, + 0x06 => Code::KeyZ, + 0x07 => Code::KeyX, + 0x08 => Code::KeyC, + 0x09 => Code::KeyV, + 0x0a => Code::IntlBackslash, + 0x0b => Code::KeyB, + 0x0c => Code::KeyQ, + 0x0d => Code::KeyW, + 0x0e => Code::KeyE, + 0x0f => Code::KeyR, + 0x10 => Code::KeyY, + 0x11 => Code::KeyT, + 0x12 => Code::Digit1, + 0x13 => Code::Digit2, + 0x14 => Code::Digit3, + 0x15 => Code::Digit4, + 0x16 => Code::Digit6, + 0x17 => Code::Digit5, + 0x18 => Code::Equal, + 0x19 => Code::Digit9, + 0x1a => Code::Digit7, + 0x1b => Code::Minus, + 0x1c => Code::Digit8, + 0x1d => Code::Digit0, + 0x1e => Code::BracketRight, + 0x1f => Code::KeyO, + 0x20 => Code::KeyU, + 0x21 => Code::BracketLeft, + 0x22 => Code::KeyI, + 0x23 => Code::KeyP, + 0x24 => Code::Enter, + 0x25 => Code::KeyL, + 0x26 => Code::KeyJ, + 0x27 => Code::Quote, + 0x28 => Code::KeyK, + 0x29 => Code::Semicolon, + 0x2a => Code::Backslash, + 0x2b => Code::Comma, + 0x2c => Code::Slash, + 0x2d => Code::KeyN, + 0x2e => Code::KeyM, + 0x2f => Code::Period, + 0x30 => Code::Tab, + 0x31 => Code::Space, + 0x32 => Code::Backquote, + 0x33 => Code::Backspace, + 0x34 => Code::NumpadEnter, + 0x35 => Code::Escape, + 0x36 => Code::MetaRight, + 0x37 => Code::MetaLeft, + 0x38 => Code::ShiftLeft, + 0x39 => Code::CapsLock, + // Note: in the linked source doc, this is "OSLeft" + 0x3a => Code::AltLeft, + 0x3b => Code::ControlLeft, + 0x3c => Code::ShiftRight, + // Note: in the linked source doc, this is "OSRight" + 0x3d => Code::AltRight, + 0x3e => Code::ControlRight, + 0x3f => Code::Fn, // No events fired + //0x40 => Code::F17, + 0x41 => Code::NumpadDecimal, + 0x43 => Code::NumpadMultiply, + 0x45 => Code::NumpadAdd, + 0x47 => Code::NumLock, + 0x48 => Code::AudioVolumeUp, + 0x49 => Code::AudioVolumeDown, + 0x4a => Code::AudioVolumeMute, + 0x4b => Code::NumpadDivide, + 0x4c => Code::NumpadEnter, + 0x4e => Code::NumpadSubtract, + //0x4f => Code::F18, + //0x50 => Code::F19, + 0x51 => Code::NumpadEqual, + 0x52 => Code::Numpad0, + 0x53 => Code::Numpad1, + 0x54 => Code::Numpad2, + 0x55 => Code::Numpad3, + 0x56 => Code::Numpad4, + 0x57 => Code::Numpad5, + 0x58 => Code::Numpad6, + 0x59 => Code::Numpad7, + //0x5a => Code::F20, + 0x5b => Code::Numpad8, + 0x5c => Code::Numpad9, + 0x5d => Code::IntlYen, + 0x5e => Code::IntlRo, + 0x5f => Code::NumpadComma, + 0x60 => Code::F5, + 0x61 => Code::F6, + 0x62 => Code::F7, + 0x63 => Code::F3, + 0x64 => Code::F8, + 0x65 => Code::F9, + 0x66 => Code::Lang2, + 0x67 => Code::F11, + 0x68 => Code::Lang1, + // Note: this is listed as F13, but in testing with a standard + // USB kb, this the code produced by PrtSc. + 0x69 => Code::PrintScreen, + //0x6a => Code::F16, + //0x6b => Code::F14, + 0x6d => Code::F10, + 0x6e => Code::ContextMenu, + 0x6f => Code::F12, + //0x71 => Code::F15, + 0x72 => Code::Help, + 0x73 => Code::Home, + 0x74 => Code::PageUp, + 0x75 => Code::Delete, + 0x76 => Code::F4, + 0x77 => Code::End, + 0x78 => Code::F2, + 0x79 => Code::PageDown, + 0x7a => Code::F1, + 0x7b => Code::ArrowLeft, + 0x7c => Code::ArrowRight, + 0x7d => Code::ArrowDown, + 0x7e => Code::ArrowUp, + _ => Code::Unidentified, + } +} + +/// Convert code to key. +/// +/// On macOS, for non-printable keys, the keyCode we get from the event serves is +/// really more of a key than a physical scan code. +/// +/// When this function returns None, the code can be considered printable. +/// +/// The logic for this function is derived from KEY_MAP_COCOA bindings in +/// NativeKeyToDOMKeyName.h. +fn code_to_key(code: Code) -> Option { + Some(match code { + Code::Escape => Key::Escape, + Code::ShiftLeft | Code::ShiftRight => Key::Shift, + Code::AltLeft | Code::AltRight => Key::Alt, + Code::MetaLeft | Code::MetaRight => Key::Meta, + Code::ControlLeft | Code::ControlRight => Key::Control, + Code::CapsLock => Key::CapsLock, + // kVK_ANSI_KeypadClear + Code::NumLock => Key::Clear, + Code::Fn => Key::Fn, + Code::F1 => Key::F1, + Code::F2 => Key::F2, + Code::F3 => Key::F3, + Code::F4 => Key::F4, + Code::F5 => Key::F5, + Code::F6 => Key::F6, + Code::F7 => Key::F7, + Code::F8 => Key::F8, + Code::F9 => Key::F9, + Code::F10 => Key::F10, + Code::F11 => Key::F11, + Code::F12 => Key::F12, + Code::Pause => Key::Pause, + Code::ScrollLock => Key::ScrollLock, + Code::PrintScreen => Key::PrintScreen, + Code::Insert => Key::Insert, + Code::Delete => Key::Delete, + Code::Tab => Key::Tab, + Code::Backspace => Key::Backspace, + Code::ContextMenu => Key::ContextMenu, + // kVK_JIS_Kana + Code::Lang1 => Key::KanjiMode, + // kVK_JIS_Eisu + Code::Lang2 => Key::Eisu, + Code::Home => Key::Home, + Code::End => Key::End, + Code::PageUp => Key::PageUp, + Code::PageDown => Key::PageDown, + Code::ArrowLeft => Key::ArrowLeft, + Code::ArrowRight => Key::ArrowRight, + Code::ArrowUp => Key::ArrowUp, + Code::ArrowDown => Key::ArrowDown, + Code::Enter => Key::Enter, + Code::NumpadEnter => Key::Enter, + Code::Help => Key::Help, + _ => return None, + }) +} + +fn is_valid_key(s: &str) -> bool { + match s.chars().next() { + None => false, + Some(c) => c >= ' ' && c != '\x7f' && !('\u{e000}'..'\u{f900}').contains(&c), + } +} + +fn is_modifier_code(code: Code) -> bool { + matches!( + code, + Code::ShiftLeft + | Code::ShiftRight + | Code::AltLeft + | Code::AltRight + | Code::ControlLeft + | Code::ControlRight + | Code::MetaLeft + | Code::MetaRight + | Code::CapsLock + | Code::Help + ) +} + +impl KeyboardState { + pub(crate) fn new() -> KeyboardState { + let last_mods = Cell::new(NSEventModifierFlags::empty()); + KeyboardState { last_mods } + } + + pub(crate) fn last_mods(&self) -> NSEventModifierFlags { + self.last_mods.get() + } + + pub(crate) fn process_native_event(&self, event: id) -> Option { + unsafe { + let event_type = event.eventType(); + let key_code = event.keyCode(); + let code = key_code_to_code(key_code); + let location = code_to_location(code); + let raw_mods = event.modifierFlags(); + let modifiers = make_modifiers(raw_mods); + let state = match event_type { + NSEventType::NSKeyDown => KeyState::Down, + NSEventType::NSKeyUp => KeyState::Up, + NSEventType::NSFlagsChanged => { + // We use `bits` here because we want to distinguish the + // device dependent bits (when both left and right keys + // may be pressed, for example). + let any_down = raw_mods.bits() & !self.last_mods.get().bits(); + self.last_mods.set(raw_mods); + if is_modifier_code(code) { + if any_down == 0 { + KeyState::Up + } else { + KeyState::Down + } + } else { + // HandleFlagsChanged has some logic for this; it might + // happen when an app is deactivated by Command-Tab. In + // that case, the best thing to do is synthesize the event + // from the modifiers. But a challenge there is that we + // might get multiple events. + return None; + } + } + _ => unreachable!(), + }; + let is_composing = false; + let repeat: bool = event_type == NSEventType::NSKeyDown && msg_send![event, isARepeat]; + let key = if let Some(key) = code_to_key(code) { + key + } else { + let characters = from_nsstring(event.characters()); + if is_valid_key(&characters) { + Key::Character(characters) + } else { + let chars_ignoring = from_nsstring(event.charactersIgnoringModifiers()); + if is_valid_key(&chars_ignoring) { + Key::Character(chars_ignoring) + } else { + // There may be more heroic things we can do here. + Key::Unidentified + } + } + }; + let event = + KeyboardEvent { code, key, location, modifiers, state, is_composing, repeat }; + Some(event) + } + } +} + +const MODIFIER_MAP: &[(NSEventModifierFlags, Modifiers)] = &[ + (NSEventModifierFlags::NSShiftKeyMask, Modifiers::SHIFT), + (NSEventModifierFlags::NSAlternateKeyMask, Modifiers::ALT), + (NSEventModifierFlags::NSControlKeyMask, Modifiers::CONTROL), + (NSEventModifierFlags::NSCommandKeyMask, Modifiers::META), + (NSEventModifierFlags::NSAlphaShiftKeyMask, Modifiers::CAPS_LOCK), +]; + +pub(crate) fn make_modifiers(raw: NSEventModifierFlags) -> Modifiers { + let mut modifiers = Modifiers::empty(); + for &(flags, mods) in MODIFIER_MAP { + if raw.contains(flags) { + modifiers |= mods; + } + } + modifiers +} diff --git a/crates/baseview/src/macos/mod.rs b/crates/baseview/src/macos/mod.rs new file mode 100644 index 0000000..d5e7f59 --- /dev/null +++ b/crates/baseview/src/macos/mod.rs @@ -0,0 +1,21 @@ +// This is required because the objc crate is causing a lot of warnings: https://github.com/SSheldon/rust-objc/issues/125 +// Eventually we should migrate to the objc2 crate and remove this. +#![allow(unexpected_cfgs)] + +mod keyboard; +mod view; +mod window; + +pub use window::*; + +#[allow(non_upper_case_globals)] +mod consts { + use cocoa::foundation::NSUInteger; + + pub const NSDragOperationNone: NSUInteger = 0; + pub const NSDragOperationCopy: NSUInteger = 1; + pub const NSDragOperationLink: NSUInteger = 2; + pub const NSDragOperationGeneric: NSUInteger = 4; + pub const NSDragOperationMove: NSUInteger = 16; +} +use consts::*; diff --git a/crates/baseview/src/macos/view.rs b/crates/baseview/src/macos/view.rs new file mode 100644 index 0000000..dd578c8 --- /dev/null +++ b/crates/baseview/src/macos/view.rs @@ -0,0 +1,556 @@ +use std::ffi::c_void; + +use cocoa::appkit::{NSEvent, NSFilenamesPboardType, NSView, NSWindow}; +use cocoa::base::{id, nil, BOOL, NO, YES}; +use cocoa::foundation::{NSArray, NSPoint, NSRect, NSSize, NSUInteger}; + +use objc::{ + class, + declare::ClassDecl, + msg_send, + runtime::{Class, Object, Sel}, + sel, sel_impl, +}; +use uuid::Uuid; + +use crate::MouseEvent::{ButtonPressed, ButtonReleased}; +use crate::{ + DropData, DropEffect, Event, EventStatus, MouseButton, MouseEvent, Point, ScrollDelta, Size, + WindowEvent, WindowInfo, WindowOpenOptions, +}; + +use super::keyboard::{from_nsstring, make_modifiers}; +use super::window::WindowState; +use super::{ + NSDragOperationCopy, NSDragOperationGeneric, NSDragOperationLink, NSDragOperationMove, + NSDragOperationNone, +}; + +/// Name of the field used to store the `WindowState` pointer. +pub(super) const BASEVIEW_STATE_IVAR: &str = "baseview_state"; + +#[link(name = "AppKit", kind = "framework")] +extern "C" { + static NSWindowDidBecomeKeyNotification: id; + static NSWindowDidResignKeyNotification: id; +} + +macro_rules! add_simple_mouse_class_method { + ($class:ident, $sel:ident, $event:expr) => { + #[allow(non_snake_case)] + extern "C" fn $sel(this: &Object, _: Sel, _: id){ + let state = unsafe { WindowState::from_view(this) }; + + state.trigger_event(Event::Mouse($event)); + } + + $class.add_method( + sel!($sel:), + $sel as extern "C" fn(&Object, Sel, id), + ); + }; +} + +/// Similar to [add_simple_mouse_class_method!], but this creates its own event object for the +/// press/release event and adds the active modifier keys to that event. +macro_rules! add_mouse_button_class_method { + ($class:ident, $sel:ident, $event_ty:ident, $button:expr) => { + #[allow(non_snake_case)] + extern "C" fn $sel(this: &Object, _: Sel, event: id){ + let state = unsafe { WindowState::from_view(this) }; + + let modifiers = unsafe { NSEvent::modifierFlags(event) }; + + state.trigger_event(Event::Mouse($event_ty { + button: $button, + modifiers: make_modifiers(modifiers), + })); + } + + $class.add_method( + sel!($sel:), + $sel as extern "C" fn(&Object, Sel, id), + ); + }; +} + +macro_rules! add_simple_keyboard_class_method { + ($class:ident, $sel:ident) => { + #[allow(non_snake_case)] + extern "C" fn $sel(this: &Object, _: Sel, event: id){ + let state = unsafe { WindowState::from_view(this) }; + + if let Some(key_event) = state.process_native_key_event(event){ + let status = state.trigger_event(Event::Keyboard(key_event)); + + if let EventStatus::Ignored = status { + unsafe { + let superclass = msg_send![this, superclass]; + + let () = msg_send![super(this, superclass), $sel:event]; + } + } + } + } + + $class.add_method( + sel!($sel:), + $sel as extern "C" fn(&Object, Sel, id), + ); + }; +} + +unsafe fn register_notification(observer: id, notification_name: id, object: id) { + let notification_center: id = msg_send![class!(NSNotificationCenter), defaultCenter]; + + let _: () = msg_send![ + notification_center, + addObserver:observer + selector:sel!(handleNotification:) + name:notification_name + object:object + ]; +} + +pub(super) unsafe fn create_view(window_options: &WindowOpenOptions) -> id { + let class = create_view_class(); + + let view: id = msg_send![class, alloc]; + + let size = window_options.size; + + view.initWithFrame_(NSRect::new(NSPoint::new(0., 0.), NSSize::new(size.width, size.height))); + + register_notification(view, NSWindowDidBecomeKeyNotification, nil); + register_notification(view, NSWindowDidResignKeyNotification, nil); + + let _: id = msg_send![ + view, + registerForDraggedTypes: NSArray::arrayWithObjects(nil, &[NSFilenamesPboardType]) + ]; + + view +} + +unsafe fn create_view_class() -> &'static Class { + // Use unique class names so that there are no conflicts between different + // instances. The class is deleted when the view is released. Previously, + // the class was stored in a OnceCell after creation. This way, we didn't + // have to recreate it each time a view was opened, but now we don't leave + // any class definitions lying around when the plugin is closed. + let class_name = format!("BaseviewNSView_{}", Uuid::new_v4().to_simple()); + let mut class = ClassDecl::new(&class_name, class!(NSView)).unwrap(); + + class.add_method( + sel!(acceptsFirstResponder), + property_yes as extern "C" fn(&Object, Sel) -> BOOL, + ); + class.add_method( + sel!(becomeFirstResponder), + become_first_responder as extern "C" fn(&Object, Sel) -> BOOL, + ); + class.add_method( + sel!(resignFirstResponder), + resign_first_responder as extern "C" fn(&Object, Sel) -> BOOL, + ); + class.add_method(sel!(isFlipped), property_yes as extern "C" fn(&Object, Sel) -> BOOL); + class.add_method( + sel!(preservesContentInLiveResize), + property_no as extern "C" fn(&Object, Sel) -> BOOL, + ); + class.add_method( + sel!(acceptsFirstMouse:), + accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + + class.add_method( + sel!(windowShouldClose:), + window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + class.add_method(sel!(dealloc), dealloc as extern "C" fn(&mut Object, Sel)); + class.add_method( + sel!(viewWillMoveToWindow:), + view_will_move_to_window as extern "C" fn(&Object, Sel, id), + ); + class.add_method( + sel!(updateTrackingAreas:), + update_tracking_areas as extern "C" fn(&Object, Sel, id), + ); + + class.add_method(sel!(mouseMoved:), mouse_moved as extern "C" fn(&Object, Sel, id)); + class.add_method(sel!(mouseDragged:), mouse_moved as extern "C" fn(&Object, Sel, id)); + class.add_method(sel!(rightMouseDragged:), mouse_moved as extern "C" fn(&Object, Sel, id)); + class.add_method(sel!(otherMouseDragged:), mouse_moved as extern "C" fn(&Object, Sel, id)); + + class.add_method(sel!(scrollWheel:), scroll_wheel as extern "C" fn(&Object, Sel, id)); + + class.add_method( + sel!(viewDidChangeBackingProperties:), + view_did_change_backing_properties as extern "C" fn(&Object, Sel, id), + ); + + class.add_method( + sel!(draggingEntered:), + dragging_entered as extern "C" fn(&Object, Sel, id) -> NSUInteger, + ); + class.add_method( + sel!(prepareForDragOperation:), + prepare_for_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + class.add_method( + sel!(performDragOperation:), + perform_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + class.add_method( + sel!(draggingUpdated:), + dragging_updated as extern "C" fn(&Object, Sel, id) -> NSUInteger, + ); + class.add_method(sel!(draggingExited:), dragging_exited as extern "C" fn(&Object, Sel, id)); + class.add_method( + sel!(handleNotification:), + handle_notification as extern "C" fn(&Object, Sel, id), + ); + + add_mouse_button_class_method!(class, mouseDown, ButtonPressed, MouseButton::Left); + add_mouse_button_class_method!(class, mouseUp, ButtonReleased, MouseButton::Left); + add_mouse_button_class_method!(class, rightMouseDown, ButtonPressed, MouseButton::Right); + add_mouse_button_class_method!(class, rightMouseUp, ButtonReleased, MouseButton::Right); + add_mouse_button_class_method!(class, otherMouseDown, ButtonPressed, MouseButton::Middle); + add_mouse_button_class_method!(class, otherMouseUp, ButtonReleased, MouseButton::Middle); + add_simple_mouse_class_method!(class, mouseEntered, MouseEvent::CursorEntered); + add_simple_mouse_class_method!(class, mouseExited, MouseEvent::CursorLeft); + + add_simple_keyboard_class_method!(class, keyDown); + add_simple_keyboard_class_method!(class, keyUp); + add_simple_keyboard_class_method!(class, flagsChanged); + + class.add_ivar::<*mut c_void>(BASEVIEW_STATE_IVAR); + + class.register() +} + +extern "C" fn property_yes(_this: &Object, _sel: Sel) -> BOOL { + YES +} + +extern "C" fn property_no(_this: &Object, _sel: Sel) -> BOOL { + NO +} + +extern "C" fn accepts_first_mouse(_this: &Object, _sel: Sel, _event: id) -> BOOL { + YES +} + +extern "C" fn become_first_responder(this: &Object, _sel: Sel) -> BOOL { + let state = unsafe { WindowState::from_view(this) }; + let is_key_window = unsafe { + let window: id = msg_send![this, window]; + if window != nil { + let is_key_window: BOOL = msg_send![window, isKeyWindow]; + is_key_window == YES + } else { + false + } + }; + if is_key_window { + state.trigger_deferrable_event(Event::Window(WindowEvent::Focused)); + } + YES +} + +extern "C" fn resign_first_responder(this: &Object, _sel: Sel) -> BOOL { + let state = unsafe { WindowState::from_view(this) }; + state.trigger_deferrable_event(Event::Window(WindowEvent::Unfocused)); + YES +} + +extern "C" fn window_should_close(this: &Object, _: Sel, _sender: id) -> BOOL { + let state = unsafe { WindowState::from_view(this) }; + + state.trigger_event(Event::Window(WindowEvent::WillClose)); + + state.window_inner.close(); + + NO +} + +extern "C" fn dealloc(this: &mut Object, _sel: Sel) { + unsafe { + let class = msg_send![this, class]; + + let superclass = msg_send![this, superclass]; + let () = msg_send![super(this, superclass), dealloc]; + + // Delete class + ::objc::runtime::objc_disposeClassPair(class); + } +} + +extern "C" fn view_did_change_backing_properties(this: &Object, _: Sel, _: id) { + unsafe { + let ns_window: *mut Object = msg_send![this, window]; + + let scale_factor: f64 = + if ns_window.is_null() { 1.0 } else { NSWindow::backingScaleFactor(ns_window) }; + + let state = WindowState::from_view(this); + + let bounds: NSRect = msg_send![this, bounds]; + + let new_window_info = WindowInfo::from_logical_size( + Size::new(bounds.size.width, bounds.size.height), + scale_factor, + ); + + let window_info = state.window_info.get(); + + // Only send the event when the window's size has actually changed to be in line with the + // other platform implementations + if new_window_info.physical_size() != window_info.physical_size() { + state.window_info.set(new_window_info); + state.trigger_event(Event::Window(WindowEvent::Resized(new_window_info))); + } + } +} + +/// Init/reinit tracking area +/// +/// Info: +/// https://developer.apple.com/documentation/appkit/nstrackingarea +/// https://developer.apple.com/documentation/appkit/nstrackingarea/options +/// https://developer.apple.com/documentation/appkit/nstrackingareaoptions +unsafe fn reinit_tracking_area(this: &Object, tracking_area: *mut Object) { + let options: usize = { + let mouse_entered_and_exited = 0x01; + let tracking_mouse_moved = 0x02; + let tracking_cursor_update = 0x04; + let tracking_active_in_active_app = 0x40; + let tracking_in_visible_rect = 0x200; + let tracking_enabled_during_mouse_drag = 0x400; + + mouse_entered_and_exited + | tracking_mouse_moved + | tracking_cursor_update + | tracking_active_in_active_app + | tracking_in_visible_rect + | tracking_enabled_during_mouse_drag + }; + + let bounds: NSRect = msg_send![this, bounds]; + + *tracking_area = msg_send![tracking_area, + initWithRect:bounds + options:options + owner:this + userInfo:nil + ]; +} + +extern "C" fn view_will_move_to_window(this: &Object, _self: Sel, new_window: id) { + unsafe { + let tracking_areas: *mut Object = msg_send![this, trackingAreas]; + let tracking_area_count = NSArray::count(tracking_areas); + + if new_window == nil { + if tracking_area_count != 0 { + let tracking_area = NSArray::objectAtIndex(tracking_areas, 0); + + let _: () = msg_send![this, removeTrackingArea: tracking_area]; + let _: () = msg_send![tracking_area, release]; + } + } else { + if tracking_area_count == 0 { + let class = Class::get("NSTrackingArea").unwrap(); + + let tracking_area: *mut Object = msg_send![class, alloc]; + + reinit_tracking_area(this, tracking_area); + + let _: () = msg_send![this, addTrackingArea: tracking_area]; + } + + let _: () = msg_send![new_window, setAcceptsMouseMovedEvents: YES]; + let _: () = msg_send![new_window, makeFirstResponder: this]; + } + } + + unsafe { + let superclass = msg_send![this, superclass]; + + let () = msg_send![super(this, superclass), viewWillMoveToWindow: new_window]; + } +} + +extern "C" fn update_tracking_areas(this: &Object, _self: Sel, _: id) { + unsafe { + let tracking_areas: *mut Object = msg_send![this, trackingAreas]; + let tracking_area = NSArray::objectAtIndex(tracking_areas, 0); + + reinit_tracking_area(this, tracking_area); + } +} + +extern "C" fn mouse_moved(this: &Object, _sel: Sel, event: id) { + let state = unsafe { WindowState::from_view(this) }; + + let point: NSPoint = unsafe { + let point = NSEvent::locationInWindow(event); + + msg_send![this, convertPoint:point fromView:nil] + }; + let modifiers = unsafe { NSEvent::modifierFlags(event) }; + + let position = Point { x: point.x, y: point.y }; + + state.trigger_event(Event::Mouse(MouseEvent::CursorMoved { + position, + modifiers: make_modifiers(modifiers), + })); +} + +extern "C" fn scroll_wheel(this: &Object, _: Sel, event: id) { + let state = unsafe { WindowState::from_view(this) }; + + let delta = unsafe { + let x = NSEvent::scrollingDeltaX(event) as f32; + let y = NSEvent::scrollingDeltaY(event) as f32; + + if NSEvent::hasPreciseScrollingDeltas(event) != NO { + ScrollDelta::Pixels { x, y } + } else { + ScrollDelta::Lines { x, y } + } + }; + + let modifiers = unsafe { NSEvent::modifierFlags(event) }; + + state.trigger_event(Event::Mouse(MouseEvent::WheelScrolled { + delta, + modifiers: make_modifiers(modifiers), + })); +} + +fn get_drag_position(sender: id) -> Point { + let point: NSPoint = unsafe { msg_send![sender, draggingLocation] }; + Point::new(point.x, point.y) +} + +fn get_drop_data(sender: id) -> DropData { + if sender == nil { + return DropData::None; + } + + unsafe { + let pasteboard: id = msg_send![sender, draggingPasteboard]; + let file_list: id = msg_send![pasteboard, propertyListForType: NSFilenamesPboardType]; + + if file_list == nil { + return DropData::None; + } + + let mut files = vec![]; + for i in 0..NSArray::count(file_list) { + let data = NSArray::objectAtIndex(file_list, i); + files.push(from_nsstring(data).into()); + } + + DropData::Files(files) + } +} + +fn on_event(window_state: &WindowState, event: MouseEvent) -> NSUInteger { + let event_status = window_state.trigger_event(Event::Mouse(event)); + match event_status { + EventStatus::AcceptDrop(DropEffect::Copy) => NSDragOperationCopy, + EventStatus::AcceptDrop(DropEffect::Move) => NSDragOperationMove, + EventStatus::AcceptDrop(DropEffect::Link) => NSDragOperationLink, + EventStatus::AcceptDrop(DropEffect::Scroll) => NSDragOperationGeneric, + _ => NSDragOperationNone, + } +} + +extern "C" fn dragging_entered(this: &Object, _sel: Sel, sender: id) -> NSUInteger { + let state = unsafe { WindowState::from_view(this) }; + let modifiers = state.keyboard_state().last_mods(); + let drop_data = get_drop_data(sender); + + let event = MouseEvent::DragEntered { + position: get_drag_position(sender), + modifiers: make_modifiers(modifiers), + data: drop_data, + }; + + on_event(&state, event) +} + +extern "C" fn dragging_updated(this: &Object, _sel: Sel, sender: id) -> NSUInteger { + let state = unsafe { WindowState::from_view(this) }; + let modifiers = state.keyboard_state().last_mods(); + let drop_data = get_drop_data(sender); + + let event = MouseEvent::DragMoved { + position: get_drag_position(sender), + modifiers: make_modifiers(modifiers), + data: drop_data, + }; + + on_event(&state, event) +} + +extern "C" fn prepare_for_drag_operation(_this: &Object, _sel: Sel, _sender: id) -> BOOL { + // Always accept drag operation if we get this far + // This function won't be called unless dragging_entered/updated + // has returned an acceptable operation + YES +} + +extern "C" fn perform_drag_operation(this: &Object, _sel: Sel, sender: id) -> BOOL { + let state = unsafe { WindowState::from_view(this) }; + let modifiers = state.keyboard_state().last_mods(); + let drop_data = get_drop_data(sender); + + let event = MouseEvent::DragDropped { + position: get_drag_position(sender), + modifiers: make_modifiers(modifiers), + data: drop_data, + }; + + let event_status = state.trigger_event(Event::Mouse(event)); + match event_status { + EventStatus::AcceptDrop(_) => YES, + _ => NO, + } +} + +extern "C" fn dragging_exited(this: &Object, _sel: Sel, _sender: id) { + let state = unsafe { WindowState::from_view(this) }; + + on_event(&state, MouseEvent::DragLeft); +} + +extern "C" fn handle_notification(this: &Object, _cmd: Sel, notification: id) { + unsafe { + let state = WindowState::from_view(this); + + // The subject of the notication, in this case an NSWindow object. + let notification_object: id = msg_send![notification, object]; + + // The NSWindow object associated with our NSView. + let window: id = msg_send![this, window]; + + let first_responder: id = msg_send![window, firstResponder]; + + // Only trigger focus events if the NSWindow that's being notified about is our window, + // and if the window's first responder is our NSView. + // If the first responder isn't our NSView, the focus events will instead be triggered + // by the becomeFirstResponder and resignFirstResponder methods on the NSView itself. + if notification_object == window && std::ptr::eq(first_responder, this) { + let is_key_window: BOOL = msg_send![window, isKeyWindow]; + state.trigger_event(Event::Window(if is_key_window == YES { + WindowEvent::Focused + } else { + WindowEvent::Unfocused + })); + } + } +} diff --git a/crates/baseview/src/macos/window.rs b/crates/baseview/src/macos/window.rs new file mode 100644 index 0000000..57bca10 --- /dev/null +++ b/crates/baseview/src/macos/window.rs @@ -0,0 +1,481 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::ffi::c_void; +use std::ptr; +use std::rc::Rc; + +use cocoa::appkit::{ + NSApp, NSApplication, NSApplicationActivationPolicyRegular, NSBackingStoreBuffered, + NSPasteboard, NSView, NSWindow, NSWindowStyleMask, +}; +use cocoa::base::{id, nil, BOOL, NO, YES}; +use cocoa::foundation::{NSAutoreleasePool, NSPoint, NSRect, NSSize, NSString}; +use core_foundation::runloop::{ + CFRunLoop, CFRunLoopTimer, CFRunLoopTimerContext, __CFRunLoopTimer, kCFRunLoopDefaultMode, +}; +use keyboard_types::KeyboardEvent; +use objc::class; +use objc::{msg_send, runtime::Object, sel, sel_impl}; +use raw_window_handle::{ + AppKitDisplayHandle, AppKitWindowHandle, HasRawDisplayHandle, HasRawWindowHandle, + RawDisplayHandle, RawWindowHandle, +}; + +use crate::{ + Event, EventStatus, MouseCursor, Size, WindowHandler, WindowInfo, WindowOpenOptions, + WindowScalePolicy, +}; + +use super::keyboard::KeyboardState; +use super::view::{create_view, BASEVIEW_STATE_IVAR}; + +#[cfg(feature = "opengl")] +use crate::gl::{GlConfig, GlContext}; + +pub struct WindowHandle { + state: Rc, +} + +impl WindowHandle { + pub fn close(&mut self) { + self.state.window_inner.close(); + } + + pub fn is_open(&self) -> bool { + self.state.window_inner.open.get() + } +} + +unsafe impl HasRawWindowHandle for WindowHandle { + fn raw_window_handle(&self) -> RawWindowHandle { + self.state.window_inner.raw_window_handle() + } +} + +pub(super) struct WindowInner { + open: Cell, + + /// Only set if we created the parent window, i.e. we are running in + /// parentless mode + ns_app: Cell>, + /// Only set if we created the parent window, i.e. we are running in + /// parentless mode + ns_window: Cell>, + /// Our subclassed NSView + ns_view: id, + + #[cfg(feature = "opengl")] + gl_context: Option, +} + +impl WindowInner { + pub(super) fn close(&self) { + if self.open.get() { + self.open.set(false); + unsafe { + // Take back ownership of the NSView's Rc + let state_ptr: *const c_void = *(*self.ns_view).get_ivar(BASEVIEW_STATE_IVAR); + let window_state = Rc::from_raw(state_ptr as *mut WindowState); + + // Cancel the frame timer + if let Some(frame_timer) = window_state.frame_timer.take() { + CFRunLoop::get_current().remove_timer(&frame_timer, kCFRunLoopDefaultMode); + } + + // Deregister NSView from NotificationCenter. + let notification_center: id = + msg_send![class!(NSNotificationCenter), defaultCenter]; + let () = msg_send![notification_center, removeObserver:self.ns_view]; + + drop(window_state); + + // Close the window if in non-parented mode + if let Some(ns_window) = self.ns_window.take() { + ns_window.close(); + } + + // Ensure that the NSView is detached from the parent window + self.ns_view.removeFromSuperview(); + let () = msg_send![self.ns_view as id, release]; + + // If in non-parented mode, we want to also quit the app altogether + let app = self.ns_app.take(); + if let Some(app) = app { + app.stop_(app); + } + } + } + } + + fn raw_window_handle(&self) -> RawWindowHandle { + if self.open.get() { + let ns_window = self.ns_window.get().unwrap_or(ptr::null_mut()) as *mut c_void; + + let mut handle = AppKitWindowHandle::empty(); + handle.ns_window = ns_window; + handle.ns_view = self.ns_view as *mut c_void; + + return RawWindowHandle::AppKit(handle); + } + + RawWindowHandle::AppKit(AppKitWindowHandle::empty()) + } +} + +pub struct Window<'a> { + inner: &'a WindowInner, +} + +impl<'a> Window<'a> { + pub fn open_parented(parent: &P, options: WindowOpenOptions, build: B) -> WindowHandle + where + P: HasRawWindowHandle, + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + let pool = unsafe { NSAutoreleasePool::new(nil) }; + + let scaling = match options.scale { + WindowScalePolicy::ScaleFactor(scale) => scale, + WindowScalePolicy::SystemScaleFactor => 1.0, + }; + + let window_info = WindowInfo::from_logical_size(options.size, scaling); + + let handle = if let RawWindowHandle::AppKit(handle) = parent.raw_window_handle() { + handle + } else { + panic!("Not a macOS window"); + }; + + let ns_view = unsafe { create_view(&options) }; + + let window_inner = WindowInner { + open: Cell::new(true), + ns_app: Cell::new(None), + ns_window: Cell::new(None), + ns_view, + + #[cfg(feature = "opengl")] + gl_context: options + .gl_config + .map(|gl_config| Self::create_gl_context(None, ns_view, gl_config)), + }; + + let window_handle = Self::init(window_inner, window_info, build); + + unsafe { + let _: id = msg_send![handle.ns_view as *mut Object, addSubview: ns_view]; + + let () = msg_send![pool, drain]; + } + + window_handle + } + + pub fn open_blocking(options: WindowOpenOptions, build: B) + where + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + let pool = unsafe { NSAutoreleasePool::new(nil) }; + + // It seems prudent to run NSApp() here before doing other + // work. It runs [NSApplication sharedApplication], which is + // what is run at the very start of the Xcode-generated main + // function of a cocoa app according to: + // https://developer.apple.com/documentation/appkit/nsapplication + let app = unsafe { NSApp() }; + + unsafe { + app.setActivationPolicy_(NSApplicationActivationPolicyRegular); + } + + let scaling = match options.scale { + WindowScalePolicy::ScaleFactor(scale) => scale, + WindowScalePolicy::SystemScaleFactor => 1.0, + }; + + let window_info = WindowInfo::from_logical_size(options.size, scaling); + + let rect = NSRect::new( + NSPoint::new(0.0, 0.0), + NSSize::new(window_info.logical_size().width, window_info.logical_size().height), + ); + + let ns_window = unsafe { + let ns_window = NSWindow::alloc(nil).initWithContentRect_styleMask_backing_defer_( + rect, + NSWindowStyleMask::NSTitledWindowMask + | NSWindowStyleMask::NSClosableWindowMask + | NSWindowStyleMask::NSMiniaturizableWindowMask, + NSBackingStoreBuffered, + NO, + ); + ns_window.center(); + + let title = NSString::alloc(nil).init_str(&options.title).autorelease(); + ns_window.setTitle_(title); + + ns_window.makeKeyAndOrderFront_(nil); + + ns_window + }; + + let ns_view = unsafe { create_view(&options) }; + + let window_inner = WindowInner { + open: Cell::new(true), + ns_app: Cell::new(Some(app)), + ns_window: Cell::new(Some(ns_window)), + ns_view, + + #[cfg(feature = "opengl")] + gl_context: options + .gl_config + .map(|gl_config| Self::create_gl_context(Some(ns_window), ns_view, gl_config)), + }; + + let _ = Self::init(window_inner, window_info, build); + + unsafe { + ns_window.setContentView_(ns_view); + ns_window.setDelegate_(ns_view); + + let () = msg_send![pool, drain]; + + app.run(); + } + } + + fn init(window_inner: WindowInner, window_info: WindowInfo, build: B) -> WindowHandle + where + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + let mut window = crate::Window::new(Window { inner: &window_inner }); + let window_handler = Box::new(build(&mut window)); + + let ns_view = window_inner.ns_view; + + let window_state = Rc::new(WindowState { + window_inner, + window_handler: RefCell::new(window_handler), + keyboard_state: KeyboardState::new(), + frame_timer: Cell::new(None), + window_info: Cell::new(window_info), + deferred_events: RefCell::default(), + }); + + let window_state_ptr = Rc::into_raw(Rc::clone(&window_state)); + + unsafe { + (*ns_view).set_ivar(BASEVIEW_STATE_IVAR, window_state_ptr as *const c_void); + + WindowState::setup_timer(window_state_ptr); + } + + WindowHandle { state: window_state } + } + + pub fn close(&mut self) { + self.inner.close(); + } + + pub fn has_focus(&mut self) -> bool { + unsafe { + let view = self.inner.ns_view.as_mut().unwrap(); + let window: id = msg_send![view, window]; + if window == nil { + return false; + }; + let first_responder: id = msg_send![window, firstResponder]; + let is_key_window: BOOL = msg_send![window, isKeyWindow]; + let is_focused: BOOL = msg_send![view, isEqual: first_responder]; + is_key_window == YES && is_focused == YES + } + } + + pub fn focus(&mut self) { + unsafe { + let view = self.inner.ns_view.as_mut().unwrap(); + let window: id = msg_send![view, window]; + if window != nil { + msg_send![window, makeFirstResponder:view] + } + } + } + + pub fn resize(&mut self, size: Size) { + if self.inner.open.get() { + // NOTE: macOS gives you a personal rave if you pass in fractional pixels here. Even + // though the size is in fractional pixels. + let size = NSSize::new(size.width.round(), size.height.round()); + + unsafe { NSView::setFrameSize(self.inner.ns_view, size) }; + unsafe { + let _: () = msg_send![self.inner.ns_view, setNeedsDisplay: YES]; + } + + // When using OpenGL the `NSOpenGLView` needs to be resized separately? Why? Because + // macOS. + #[cfg(feature = "opengl")] + if let Some(gl_context) = &self.inner.gl_context { + gl_context.resize(size); + } + + // If this is a standalone window then we'll also need to resize the window itself + if let Some(ns_window) = self.inner.ns_window.get() { + unsafe { NSWindow::setContentSize_(ns_window, size) }; + } + } + } + + pub fn set_mouse_cursor(&mut self, _mouse_cursor: MouseCursor) { + todo!() + } + + #[cfg(feature = "opengl")] + pub fn gl_context(&self) -> Option<&GlContext> { + self.inner.gl_context.as_ref() + } + + #[cfg(feature = "opengl")] + fn create_gl_context(ns_window: Option, ns_view: id, config: GlConfig) -> GlContext { + let mut handle = AppKitWindowHandle::empty(); + handle.ns_window = ns_window.unwrap_or(ptr::null_mut()) as *mut c_void; + handle.ns_view = ns_view as *mut c_void; + let handle = RawWindowHandle::AppKit(handle); + + unsafe { GlContext::create(&handle, config).expect("Could not create OpenGL context") } + } +} + +pub(super) struct WindowState { + pub(super) window_inner: WindowInner, + window_handler: RefCell>, + keyboard_state: KeyboardState, + frame_timer: Cell>, + /// The last known window info for this window. + pub window_info: Cell, + + /// Events that will be triggered at the end of `window_handler`'s borrow. + deferred_events: RefCell>, +} + +impl WindowState { + /// Gets the `WindowState` held by a given `NSView`. + /// + /// This method returns a cloned `Rc` rather than just a `&WindowState`, since the + /// original `Rc` owned by the `NSView` can be dropped at any time + /// (including during an event handler). + pub(super) unsafe fn from_view(view: &Object) -> Rc { + let state_ptr: *const c_void = *view.get_ivar(BASEVIEW_STATE_IVAR); + + let state_rc = Rc::from_raw(state_ptr as *const WindowState); + let state = Rc::clone(&state_rc); + let _ = Rc::into_raw(state_rc); + + state + } + + /// Trigger the event immediately and return the event status. + /// Will panic if `window_handler` is already borrowed (see `trigger_deferrable_event`). + pub(super) fn trigger_event(&self, event: Event) -> EventStatus { + let mut window = crate::Window::new(Window { inner: &self.window_inner }); + let mut window_handler = self.window_handler.borrow_mut(); + let status = window_handler.on_event(&mut window, event); + self.send_deferred_events(window_handler.as_mut()); + status + } + + /// Trigger the event immediately if `window_handler` can be borrowed mutably, + /// otherwise add the event to a queue that will be cleared once `window_handler`'s mutable borrow ends. + /// As this method might result in the event triggering asynchronously, it can't reliably return the event status. + pub(super) fn trigger_deferrable_event(&self, event: Event) { + if let Ok(mut window_handler) = self.window_handler.try_borrow_mut() { + let mut window = crate::Window::new(Window { inner: &self.window_inner }); + window_handler.on_event(&mut window, event); + self.send_deferred_events(window_handler.as_mut()); + } else { + self.deferred_events.borrow_mut().push_back(event); + } + } + + pub(super) fn trigger_frame(&self) { + let mut window = crate::Window::new(Window { inner: &self.window_inner }); + let mut window_handler = self.window_handler.borrow_mut(); + window_handler.on_frame(&mut window); + self.send_deferred_events(window_handler.as_mut()); + } + + pub(super) fn keyboard_state(&self) -> &KeyboardState { + &self.keyboard_state + } + + pub(super) fn process_native_key_event(&self, event: *mut Object) -> Option { + self.keyboard_state.process_native_event(event) + } + + unsafe fn setup_timer(window_state_ptr: *const WindowState) { + extern "C" fn timer_callback(_: *mut __CFRunLoopTimer, window_state_ptr: *mut c_void) { + unsafe { + let window_state = &*(window_state_ptr as *const WindowState); + + window_state.trigger_frame(); + } + } + + let mut timer_context = CFRunLoopTimerContext { + version: 0, + info: window_state_ptr as *mut c_void, + retain: None, + release: None, + copyDescription: None, + }; + + let timer = CFRunLoopTimer::new(0.0, 0.015, 0, 0, timer_callback, &mut timer_context); + + CFRunLoop::get_current().add_timer(&timer, kCFRunLoopDefaultMode); + + (*window_state_ptr).frame_timer.set(Some(timer)); + } + + fn send_deferred_events(&self, window_handler: &mut dyn WindowHandler) { + let mut window = crate::Window::new(Window { inner: &self.window_inner }); + loop { + let next_event = self.deferred_events.borrow_mut().pop_front(); + if let Some(event) = next_event { + window_handler.on_event(&mut window, event); + } else { + break; + } + } + } +} + +unsafe impl<'a> HasRawWindowHandle for Window<'a> { + fn raw_window_handle(&self) -> RawWindowHandle { + self.inner.raw_window_handle() + } +} + +unsafe impl<'a> HasRawDisplayHandle for Window<'a> { + fn raw_display_handle(&self) -> RawDisplayHandle { + RawDisplayHandle::AppKit(AppKitDisplayHandle::empty()) + } +} + +pub fn copy_to_clipboard(string: &str) { + unsafe { + let pb = NSPasteboard::generalPasteboard(nil); + + let ns_str = NSString::alloc(nil).init_str(string); + + pb.clearContents(); + pb.setString_forType(ns_str, cocoa::appkit::NSPasteboardTypeString); + } +} diff --git a/crates/baseview/src/mouse_cursor.rs b/crates/baseview/src/mouse_cursor.rs new file mode 100644 index 0000000..bf4cde0 --- /dev/null +++ b/crates/baseview/src/mouse_cursor.rs @@ -0,0 +1,44 @@ +#[derive(Debug, Default, Eq, PartialEq, Clone, Copy, PartialOrd, Ord, Hash)] +pub enum MouseCursor { + #[default] + Default, + Hand, + HandGrabbing, + Help, + + Hidden, + + Text, + VerticalText, + + Working, + PtrWorking, + + NotAllowed, + PtrNotAllowed, + + ZoomIn, + ZoomOut, + + Alias, + Copy, + Move, + AllScroll, + Cell, + Crosshair, + + EResize, + NResize, + NeResize, + NwResize, + SResize, + SeResize, + SwResize, + WResize, + EwResize, + NsResize, + NwseResize, + NeswResize, + ColResize, + RowResize, +} diff --git a/crates/baseview/src/win/cursor.rs b/crates/baseview/src/win/cursor.rs new file mode 100644 index 0000000..f9a04d3 --- /dev/null +++ b/crates/baseview/src/win/cursor.rs @@ -0,0 +1,54 @@ +use crate::MouseCursor; +use winapi::{ + shared::ntdef::LPCWSTR, + um::winuser::{ + IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, + IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE, IDC_SIZEWE, IDC_WAIT, + }, +}; + +pub fn cursor_to_lpcwstr(cursor: MouseCursor) -> LPCWSTR { + match cursor { + MouseCursor::Default => IDC_ARROW, + MouseCursor::Hand => IDC_HAND, + MouseCursor::HandGrabbing => IDC_SIZEALL, + MouseCursor::Help => IDC_HELP, + // an empty LPCWSTR results in the cursor being hidden + MouseCursor::Hidden => std::ptr::null(), + + MouseCursor::Text => IDC_IBEAM, + MouseCursor::VerticalText => IDC_IBEAM, + + MouseCursor::Working => IDC_WAIT, + MouseCursor::PtrWorking => IDC_APPSTARTING, + + MouseCursor::NotAllowed => IDC_NO, + MouseCursor::PtrNotAllowed => IDC_NO, + + MouseCursor::ZoomIn => IDC_ARROW, + MouseCursor::ZoomOut => IDC_ARROW, + + MouseCursor::Alias => IDC_ARROW, + MouseCursor::Copy => IDC_ARROW, + MouseCursor::Move => IDC_SIZEALL, + MouseCursor::AllScroll => IDC_SIZEALL, + MouseCursor::Cell => IDC_CROSS, + MouseCursor::Crosshair => IDC_CROSS, + + MouseCursor::EResize => IDC_SIZEWE, + MouseCursor::NResize => IDC_SIZENS, + MouseCursor::NeResize => IDC_SIZENESW, + MouseCursor::NwResize => IDC_SIZENWSE, + MouseCursor::SResize => IDC_SIZENS, + MouseCursor::SeResize => IDC_SIZENWSE, + MouseCursor::SwResize => IDC_SIZENESW, + MouseCursor::WResize => IDC_SIZEWE, + MouseCursor::EwResize => IDC_SIZEWE, + MouseCursor::NsResize => IDC_SIZENS, + MouseCursor::NwseResize => IDC_SIZENWSE, + MouseCursor::NeswResize => IDC_SIZENESW, + + MouseCursor::ColResize => IDC_SIZEWE, + MouseCursor::RowResize => IDC_SIZENS, + } +} diff --git a/crates/baseview/src/win/drop_target.rs b/crates/baseview/src/win/drop_target.rs new file mode 100644 index 0000000..b9ba580 --- /dev/null +++ b/crates/baseview/src/win/drop_target.rs @@ -0,0 +1,282 @@ +use std::ffi::OsString; +use std::mem::transmute; +use std::os::windows::prelude::OsStringExt; +use std::ptr::null_mut; +use std::rc::{Rc, Weak}; + +use winapi::shared::guiddef::{IsEqualIID, REFIID}; +use winapi::shared::minwindef::{DWORD, WPARAM}; +use winapi::shared::ntdef::{HRESULT, ULONG}; +use winapi::shared::windef::{POINT, POINTL}; +use winapi::shared::winerror::{E_NOINTERFACE, E_UNEXPECTED, S_OK}; +use winapi::shared::wtypes::DVASPECT_CONTENT; +use winapi::um::objidl::{IDataObject, FORMATETC, STGMEDIUM, TYMED_HGLOBAL}; +use winapi::um::oleidl::{ + IDropTarget, IDropTargetVtbl, DROPEFFECT_COPY, DROPEFFECT_LINK, DROPEFFECT_MOVE, + DROPEFFECT_NONE, DROPEFFECT_SCROLL, +}; +use winapi::um::shellapi::{DragQueryFileW, HDROP}; +use winapi::um::unknwnbase::{IUnknown, IUnknownVtbl}; +use winapi::um::winuser::{ScreenToClient, CF_HDROP}; +use winapi::Interface; + +use crate::{DropData, DropEffect, Event, EventStatus, MouseEvent, PhyPoint, Point}; + +use super::WindowState; + +// These function pointers have to be stored in a (const) variable before they can be transmuted +// Transmuting is needed because winapi has a bug where the pt parameter has an incorrect +// type `*const POINTL` +#[allow(non_snake_case)] +const DRAG_ENTER_PTR: unsafe extern "system" fn( + this: *mut IDropTarget, + pDataObj: *const IDataObject, + grfKeyState: DWORD, + pt: POINTL, + pdwEffect: *mut DWORD, +) -> HRESULT = DropTarget::drag_enter; +#[allow(non_snake_case)] +const DRAG_OVER_PTR: unsafe extern "system" fn( + this: *mut IDropTarget, + grfKeyState: DWORD, + pt: POINTL, + pdwEffect: *mut DWORD, +) -> HRESULT = DropTarget::drag_over; +#[allow(non_snake_case)] +const DROP_PTR: unsafe extern "system" fn( + this: *mut IDropTarget, + pDataObj: *const IDataObject, + grfKeyState: DWORD, + pt: POINTL, + pdwEffect: *mut DWORD, +) -> HRESULT = DropTarget::drop; + +#[allow(clippy::missing_transmute_annotations)] +const DROP_TARGET_VTBL: IDropTargetVtbl = IDropTargetVtbl { + parent: IUnknownVtbl { + QueryInterface: DropTarget::query_interface, + AddRef: DropTarget::add_ref, + Release: DropTarget::release, + }, + DragEnter: unsafe { transmute(DRAG_ENTER_PTR) }, + DragOver: unsafe { transmute(DRAG_OVER_PTR) }, + DragLeave: DropTarget::drag_leave, + Drop: unsafe { transmute(DROP_PTR) }, +}; + +#[repr(C)] +pub(super) struct DropTarget { + base: IDropTarget, + + window_state: Weak, + + // These are cached since DragOver and DragLeave callbacks don't provide them, + // and handling drag move events gets awkward on the client end otherwise + drag_position: Point, + drop_data: DropData, +} + +impl DropTarget { + pub(super) fn new(window_state: Weak) -> Self { + Self { + base: IDropTarget { lpVtbl: &DROP_TARGET_VTBL }, + + window_state, + + drag_position: Point::new(0.0, 0.0), + drop_data: DropData::None, + } + } + + #[allow(non_snake_case)] + fn on_event(&self, pdwEffect: Option<*mut DWORD>, event: MouseEvent) { + let Some(window_state) = self.window_state.upgrade() else { + return; + }; + + unsafe { + let mut window = crate::Window::new(window_state.create_window()); + + let event = Event::Mouse(event); + let event_status = + window_state.handler_mut().as_mut().unwrap().on_event(&mut window, event); + + if let Some(pdwEffect) = pdwEffect { + match event_status { + EventStatus::AcceptDrop(DropEffect::Copy) => *pdwEffect = DROPEFFECT_COPY, + EventStatus::AcceptDrop(DropEffect::Move) => *pdwEffect = DROPEFFECT_MOVE, + EventStatus::AcceptDrop(DropEffect::Link) => *pdwEffect = DROPEFFECT_LINK, + EventStatus::AcceptDrop(DropEffect::Scroll) => *pdwEffect = DROPEFFECT_SCROLL, + _ => *pdwEffect = DROPEFFECT_NONE, + } + } + } + } + + fn parse_coordinates(&mut self, pt: POINTL) { + let Some(window_state) = self.window_state.upgrade() else { + return; + }; + let mut pt = POINT { x: pt.x, y: pt.y }; + unsafe { ScreenToClient(window_state.hwnd, &mut pt as *mut POINT) }; + let phy_point = PhyPoint::new(pt.x, pt.y); + self.drag_position = phy_point.to_logical(&window_state.window_info()); + } + + fn parse_drop_data(&mut self, data_object: &IDataObject) { + let format = FORMATETC { + cfFormat: CF_HDROP as u16, + ptd: null_mut(), + dwAspect: DVASPECT_CONTENT, + lindex: -1, + tymed: TYMED_HGLOBAL, + }; + + let mut medium = STGMEDIUM { tymed: 0, u: null_mut(), pUnkForRelease: null_mut() }; + + unsafe { + let hresult = data_object.GetData(&format, &mut medium); + if hresult != S_OK { + self.drop_data = DropData::None; + return; + } + + let hdrop = *(*medium.u).hGlobal() as HDROP; + + let item_count = DragQueryFileW(hdrop, 0xFFFFFFFF, null_mut(), 0); + if item_count == 0 { + self.drop_data = DropData::None; + return; + } + + let mut paths = Vec::with_capacity(item_count as usize); + + for i in 0..item_count { + let characters = DragQueryFileW(hdrop, i, null_mut(), 0); + let buffer_size = characters as usize + 1; + let mut buffer = vec![0u16; buffer_size]; + + DragQueryFileW(hdrop, i, buffer.as_mut_ptr().cast(), buffer_size as u32); + + paths.push(OsString::from_wide(&buffer[..characters as usize]).into()) + } + + self.drop_data = DropData::Files(paths); + } + } + + #[allow(non_snake_case)] + unsafe extern "system" fn query_interface( + this: *mut IUnknown, riid: REFIID, ppvObject: *mut *mut winapi::ctypes::c_void, + ) -> HRESULT { + if IsEqualIID(&*riid, &IUnknown::uuidof()) || IsEqualIID(&*riid, &IDropTarget::uuidof()) { + Self::add_ref(this); + *ppvObject = this as *mut winapi::ctypes::c_void; + return S_OK; + } + + E_NOINTERFACE + } + + unsafe extern "system" fn add_ref(this: *mut IUnknown) -> ULONG { + let arc = Rc::from_raw(this); + let result = Rc::strong_count(&arc) + 1; + let _ = Rc::into_raw(arc); + + Rc::increment_strong_count(this); + + result as ULONG + } + + unsafe extern "system" fn release(this: *mut IUnknown) -> ULONG { + let arc = Rc::from_raw(this); + let result = Rc::strong_count(&arc) - 1; + let _ = Rc::into_raw(arc); + + Rc::decrement_strong_count(this); + + result as ULONG + } + + #[allow(non_snake_case)] + unsafe extern "system" fn drag_enter( + this: *mut IDropTarget, pDataObj: *const IDataObject, grfKeyState: DWORD, pt: POINTL, + pdwEffect: *mut DWORD, + ) -> HRESULT { + let drop_target = &mut *(this as *mut DropTarget); + let Some(window_state) = drop_target.window_state.upgrade() else { + return E_UNEXPECTED; + }; + + let modifiers = + window_state.keyboard_state().get_modifiers_from_mouse_wparam(grfKeyState as WPARAM); + + drop_target.parse_coordinates(pt); + drop_target.parse_drop_data(&*pDataObj); + + let event = MouseEvent::DragEntered { + position: drop_target.drag_position, + modifiers, + data: drop_target.drop_data.clone(), + }; + + drop_target.on_event(Some(pdwEffect), event); + S_OK + } + + #[allow(non_snake_case)] + unsafe extern "system" fn drag_over( + this: *mut IDropTarget, grfKeyState: DWORD, pt: POINTL, pdwEffect: *mut DWORD, + ) -> HRESULT { + let drop_target = &mut *(this as *mut DropTarget); + let Some(window_state) = drop_target.window_state.upgrade() else { + return E_UNEXPECTED; + }; + + let modifiers = + window_state.keyboard_state().get_modifiers_from_mouse_wparam(grfKeyState as WPARAM); + + drop_target.parse_coordinates(pt); + + let event = MouseEvent::DragMoved { + position: drop_target.drag_position, + modifiers, + data: drop_target.drop_data.clone(), + }; + + drop_target.on_event(Some(pdwEffect), event); + S_OK + } + + unsafe extern "system" fn drag_leave(this: *mut IDropTarget) -> HRESULT { + let drop_target = &mut *(this as *mut DropTarget); + drop_target.on_event(None, MouseEvent::DragLeft); + S_OK + } + + #[allow(non_snake_case)] + unsafe extern "system" fn drop( + this: *mut IDropTarget, pDataObj: *const IDataObject, grfKeyState: DWORD, pt: POINTL, + pdwEffect: *mut DWORD, + ) -> HRESULT { + let drop_target = &mut *(this as *mut DropTarget); + let Some(window_state) = drop_target.window_state.upgrade() else { + return E_UNEXPECTED; + }; + + let modifiers = + window_state.keyboard_state().get_modifiers_from_mouse_wparam(grfKeyState as WPARAM); + + drop_target.parse_coordinates(pt); + drop_target.parse_drop_data(&*pDataObj); + + let event = MouseEvent::DragDropped { + position: drop_target.drag_position, + modifiers, + data: drop_target.drop_data.clone(), + }; + + drop_target.on_event(Some(pdwEffect), event); + S_OK + } +} diff --git a/crates/baseview/src/win/hook.rs b/crates/baseview/src/win/hook.rs new file mode 100644 index 0000000..0f6a3e8 --- /dev/null +++ b/crates/baseview/src/win/hook.rs @@ -0,0 +1,142 @@ +use std::{ + collections::HashSet, + ffi::c_int, + ptr, + sync::{LazyLock, RwLock}, +}; + +use winapi::{ + shared::{ + minwindef::{LPARAM, WPARAM}, + windef::{HHOOK, HWND, POINT}, + }, + um::{ + libloaderapi::GetModuleHandleW, + processthreadsapi::GetCurrentThreadId, + winuser::{ + CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HC_ACTION, MSG, PM_REMOVE, + WH_GETMESSAGE, WM_CHAR, WM_KEYDOWN, WM_KEYUP, WM_SYSCHAR, WM_SYSKEYDOWN, WM_SYSKEYUP, + WM_USER, + }, + }, +}; + +use crate::win::wnd_proc; + +// track all windows opened by this instance of baseview +// we use an RwLock here since the vast majority of uses (event interceptions) +// will only need to read from the HashSet +static HOOK_STATE: LazyLock> = LazyLock::new(|| RwLock::default()); + +pub(crate) struct KeyboardHookHandle(HWNDWrapper); + +#[derive(Default)] +struct KeyboardHookState { + hook: Option, + open_windows: HashSet, +} + +#[derive(Hash, PartialEq, Eq, Clone, Copy)] +struct HWNDWrapper(HWND); + +// SAFETY: it's a pointer behind an RwLock. we'll live +unsafe impl Send for KeyboardHookState {} +unsafe impl Sync for KeyboardHookState {} + +// SAFETY: we never access the underlying HWND ourselves, just use it as a HashSet entry +unsafe impl Send for HWNDWrapper {} +unsafe impl Sync for HWNDWrapper {} + +impl Drop for KeyboardHookHandle { + fn drop(&mut self) { + deinit_keyboard_hook(self.0); + } +} + +// initialize keyboard hook +// some DAWs (particularly Ableton) intercept incoming keyboard messages, +// but we're naughty so we intercept them right back +pub(crate) fn init_keyboard_hook(hwnd: HWND) -> KeyboardHookHandle { + let state = &mut *HOOK_STATE.write().unwrap(); + + // register hwnd to global window set + state.open_windows.insert(HWNDWrapper(hwnd)); + + if state.hook.is_some() { + // keyboard hook already exists, just return handle + KeyboardHookHandle(HWNDWrapper(hwnd)) + } else { + // keyboard hook doesn't exist (no windows open before this), create it + let new_hook = unsafe { + SetWindowsHookExW( + WH_GETMESSAGE, + Some(keyboard_hook_callback), + GetModuleHandleW(ptr::null()), + GetCurrentThreadId(), + ) + }; + + state.hook = Some(new_hook); + + KeyboardHookHandle(HWNDWrapper(hwnd)) + } +} + +fn deinit_keyboard_hook(hwnd: HWNDWrapper) { + let state = &mut *HOOK_STATE.write().unwrap(); + + state.open_windows.remove(&hwnd); + + if state.open_windows.is_empty() { + if let Some(hhook) = state.hook { + unsafe { + UnhookWindowsHookEx(hhook); + } + + state.hook = None; + } + } +} + +unsafe extern "system" fn keyboard_hook_callback( + n_code: c_int, wparam: WPARAM, lparam: LPARAM, +) -> isize { + let msg = lparam as *mut MSG; + + if n_code == HC_ACTION && wparam == PM_REMOVE as usize && offer_message_to_baseview(msg) { + *msg = MSG { + hwnd: ptr::null_mut(), + message: WM_USER, + wParam: 0, + lParam: 0, + time: 0, + pt: POINT { x: 0, y: 0 }, + }; + + 0 + } else { + CallNextHookEx(ptr::null_mut(), n_code, wparam, lparam) + } +} + +// check if `msg` is a keyboard message addressed to a window +// in KeyboardHookState::open_windows, and intercept it if so +unsafe fn offer_message_to_baseview(msg: *mut MSG) -> bool { + let msg = &*msg; + + // if this isn't a keyboard message, ignore it + match msg.message { + WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP | WM_CHAR | WM_SYSCHAR => {} + + _ => return false, + } + + // check if this is one of our windows. if so, intercept it + if HOOK_STATE.read().unwrap().open_windows.contains(&HWNDWrapper(msg.hwnd)) { + let _ = wnd_proc(msg.hwnd, msg.message, msg.wParam, msg.lParam); + + return true; + } + + false +} diff --git a/crates/baseview/src/win/keyboard.rs b/crates/baseview/src/win/keyboard.rs new file mode 100644 index 0000000..ed696cc --- /dev/null +++ b/crates/baseview/src/win/keyboard.rs @@ -0,0 +1,704 @@ +// Copyright 2020 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Baseview modifications to druid code: +// - update imports, paths etc + +//! Key event handling. + +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; +use std::mem; +use std::ops::RangeInclusive; + +use keyboard_types::{Code, Key, KeyState, KeyboardEvent, Location, Modifiers}; + +use winapi::shared::minwindef::{HKL, INT, LPARAM, UINT, WPARAM}; +use winapi::shared::ntdef::SHORT; +use winapi::shared::windef::HWND; +use winapi::um::winuser::{ + GetKeyState, GetKeyboardLayout, MapVirtualKeyExW, PeekMessageW, ToUnicodeEx, MAPVK_VK_TO_CHAR, + MAPVK_VSC_TO_VK_EX, MK_CONTROL, MK_SHIFT, PM_NOREMOVE, VK_ACCEPT, VK_ADD, VK_APPS, VK_ATTN, + VK_BACK, VK_BROWSER_BACK, VK_BROWSER_FAVORITES, VK_BROWSER_FORWARD, VK_BROWSER_HOME, + VK_BROWSER_REFRESH, VK_BROWSER_SEARCH, VK_BROWSER_STOP, VK_CANCEL, VK_CAPITAL, VK_CLEAR, + VK_CONTROL, VK_CONVERT, VK_CRSEL, VK_DECIMAL, VK_DELETE, VK_DIVIDE, VK_DOWN, VK_END, VK_EREOF, + VK_ESCAPE, VK_EXECUTE, VK_EXSEL, VK_F1, VK_F10, VK_F11, VK_F12, VK_F2, VK_F3, VK_F4, VK_F5, + VK_F6, VK_F7, VK_F8, VK_F9, VK_FINAL, VK_HELP, VK_HOME, VK_INSERT, VK_JUNJA, VK_KANA, VK_KANJI, + VK_LAUNCH_APP1, VK_LAUNCH_APP2, VK_LAUNCH_MAIL, VK_LAUNCH_MEDIA_SELECT, VK_LCONTROL, VK_LEFT, + VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MEDIA_NEXT_TRACK, VK_MEDIA_PLAY_PAUSE, VK_MEDIA_PREV_TRACK, + VK_MEDIA_STOP, VK_MENU, VK_MODECHANGE, VK_MULTIPLY, VK_NEXT, VK_NONCONVERT, VK_NUMLOCK, + VK_NUMPAD0, VK_NUMPAD1, VK_NUMPAD2, VK_NUMPAD3, VK_NUMPAD4, VK_NUMPAD5, VK_NUMPAD6, VK_NUMPAD7, + VK_NUMPAD8, VK_NUMPAD9, VK_OEM_ATTN, VK_OEM_CLEAR, VK_PAUSE, VK_PLAY, VK_PRINT, VK_PRIOR, + VK_PROCESSKEY, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SCROLL, + VK_SELECT, VK_SHIFT, VK_SLEEP, VK_SNAPSHOT, VK_SUBTRACT, VK_TAB, VK_UP, VK_VOLUME_DOWN, + VK_VOLUME_MUTE, VK_VOLUME_UP, VK_ZOOM, WM_CHAR, WM_INPUTLANGCHANGE, WM_KEYDOWN, WM_KEYUP, + WM_SYSCHAR, WM_SYSKEYDOWN, WM_SYSKEYUP, +}; + +const VK_ABNT_C2: INT = 0xc2; + +/// A (non-extended) virtual key code. +type VkCode = u8; + +// This is really bitfields. +type ShiftState = u8; +const SHIFT_STATE_SHIFT: ShiftState = 1; +const SHIFT_STATE_ALTGR: ShiftState = 2; +const N_SHIFT_STATE: ShiftState = 4; + +/// Per-window keyboard state. +pub(super) struct KeyboardState { + hkl: HKL, + // A map from (vk, is_shifted) to string val + key_vals: HashMap<(VkCode, ShiftState), String>, + dead_keys: HashSet<(VkCode, ShiftState)>, + has_altgr: bool, + stash_vk: Option, + stash_utf16: Vec, +} + +/// Virtual key codes that are considered printable. +/// +/// This logic is borrowed from KeyboardLayout::GetKeyIndex +/// in Mozilla. +const PRINTABLE_VKS: &[RangeInclusive] = &[ + 0x20..=0x20, + 0x30..=0x39, + 0x41..=0x5A, + 0x60..=0x6B, + 0x6D..=0x6F, + 0xBA..=0xC2, + 0xDB..=0xDF, + 0xE1..=0xE4, +]; + +/// Bits of lparam indicating scan code, including extended bit. +const SCAN_MASK: LPARAM = 0x1ff_0000; + +/// Determine whether there are more messages in the queue for this key event. +/// +/// When this function returns `false`, there is another message in the queue +/// with a matching scan code, therefore it is reasonable to stash the data +/// from this message and defer til later to actually produce the event. +unsafe fn is_last_message(hwnd: HWND, msg: UINT, lparam: LPARAM) -> bool { + let expected_msg = match msg { + WM_KEYDOWN | WM_CHAR => WM_CHAR, + WM_SYSKEYDOWN | WM_SYSCHAR => WM_SYSCHAR, + _ => unreachable!(), + }; + let mut msg = mem::zeroed(); + let avail = PeekMessageW(&mut msg, hwnd, expected_msg, expected_msg, PM_NOREMOVE); + avail == 0 || msg.lParam & SCAN_MASK != lparam & SCAN_MASK +} + +const MODIFIER_MAP: &[(INT, Modifiers, SHORT)] = &[ + (VK_MENU, Modifiers::ALT, 0x80), + (VK_CAPITAL, Modifiers::CAPS_LOCK, 0x1), + (VK_CONTROL, Modifiers::CONTROL, 0x80), + (VK_NUMLOCK, Modifiers::NUM_LOCK, 0x1), + (VK_SCROLL, Modifiers::SCROLL_LOCK, 0x1), + (VK_SHIFT, Modifiers::SHIFT, 0x80), +]; + +/// Convert scan code to W3C standard code. +/// +/// It's hard to get an authoritative source for this; it's mostly based +/// on NativeKeyToDOMCodeName.h in Mozilla. +fn scan_to_code(scan_code: u32) -> Code { + use Code::*; + match scan_code { + 0x1 => Escape, + 0x2 => Digit1, + 0x3 => Digit2, + 0x4 => Digit3, + 0x5 => Digit4, + 0x6 => Digit5, + 0x7 => Digit6, + 0x8 => Digit7, + 0x9 => Digit8, + 0xA => Digit9, + 0xB => Digit0, + 0xC => Minus, + 0xD => Equal, + 0xE => Backspace, + 0xF => Tab, + 0x10 => KeyQ, + 0x11 => KeyW, + 0x12 => KeyE, + 0x13 => KeyR, + 0x14 => KeyT, + 0x15 => KeyY, + 0x16 => KeyU, + 0x17 => KeyI, + 0x18 => KeyO, + 0x19 => KeyP, + 0x1A => BracketLeft, + 0x1B => BracketRight, + 0x1C => Enter, + 0x1D => ControlLeft, + 0x1E => KeyA, + 0x1F => KeyS, + 0x20 => KeyD, + 0x21 => KeyF, + 0x22 => KeyG, + 0x23 => KeyH, + 0x24 => KeyJ, + 0x25 => KeyK, + 0x26 => KeyL, + 0x27 => Semicolon, + 0x28 => Quote, + 0x29 => Backquote, + 0x2A => ShiftLeft, + 0x2B => Backslash, + 0x2C => KeyZ, + 0x2D => KeyX, + 0x2E => KeyC, + 0x2F => KeyV, + 0x30 => KeyB, + 0x31 => KeyN, + 0x32 => KeyM, + 0x33 => Comma, + 0x34 => Period, + 0x35 => Slash, + 0x36 => ShiftRight, + 0x37 => NumpadMultiply, + 0x38 => AltLeft, + 0x39 => Space, + 0x3A => CapsLock, + 0x3B => F1, + 0x3C => F2, + 0x3D => F3, + 0x3E => F4, + 0x3F => F5, + 0x40 => F6, + 0x41 => F7, + 0x42 => F8, + 0x43 => F9, + 0x44 => F10, + 0x45 => Pause, + 0x46 => ScrollLock, + 0x47 => Numpad7, + 0x48 => Numpad8, + 0x49 => Numpad9, + 0x4A => NumpadSubtract, + 0x4B => Numpad4, + 0x4C => Numpad5, + 0x4D => Numpad6, + 0x4E => NumpadAdd, + 0x4F => Numpad1, + 0x50 => Numpad2, + 0x51 => Numpad3, + 0x52 => Numpad0, + 0x53 => NumpadDecimal, + 0x54 => PrintScreen, + 0x56 => IntlBackslash, + 0x57 => F11, + 0x58 => F12, + 0x59 => NumpadEqual, + 0x70 => KanaMode, + 0x71 => Lang2, + 0x72 => Lang1, + 0x73 => IntlRo, + 0x79 => Convert, + 0x7B => NonConvert, + 0x7D => IntlYen, + 0x7E => NumpadComma, + 0x110 => MediaTrackPrevious, + 0x119 => MediaTrackNext, + 0x11C => NumpadEnter, + 0x11D => ControlRight, + 0x120 => AudioVolumeMute, + 0x121 => LaunchApp2, + 0x122 => MediaPlayPause, + 0x124 => MediaStop, + 0x12E => AudioVolumeDown, + 0x130 => AudioVolumeUp, + 0x132 => BrowserHome, + 0x135 => NumpadDivide, + 0x137 => PrintScreen, + 0x138 => AltRight, + 0x145 => NumLock, + 0x147 => Home, + 0x148 => ArrowUp, + 0x149 => PageUp, + 0x14B => ArrowLeft, + 0x14D => ArrowRight, + 0x14F => End, + 0x150 => ArrowDown, + 0x151 => PageDown, + 0x152 => Insert, + 0x153 => Delete, + 0x15B => MetaLeft, + 0x15C => MetaRight, + 0x15D => ContextMenu, + 0x15E => Power, + 0x165 => BrowserSearch, + 0x166 => BrowserFavorites, + 0x167 => BrowserRefresh, + 0x168 => BrowserStop, + 0x169 => BrowserForward, + 0x16A => BrowserBack, + 0x16B => LaunchApp1, + 0x16C => LaunchMail, + 0x16D => MediaSelect, + 0x1F1 => Lang2, + 0x1F2 => Lang1, + _ => Unidentified, + } +} + +fn vk_to_key(vk: VkCode) -> Option { + Some(match vk as INT { + VK_CANCEL => Key::Cancel, + VK_BACK => Key::Backspace, + VK_TAB => Key::Tab, + VK_CLEAR => Key::Clear, + VK_RETURN => Key::Enter, + VK_SHIFT | VK_LSHIFT | VK_RSHIFT => Key::Shift, + VK_CONTROL | VK_LCONTROL | VK_RCONTROL => Key::Control, + VK_MENU | VK_LMENU | VK_RMENU => Key::Alt, + VK_PAUSE => Key::Pause, + VK_CAPITAL => Key::CapsLock, + // TODO: disambiguate kana and hangul? same vk + VK_KANA => Key::KanaMode, + VK_JUNJA => Key::JunjaMode, + VK_FINAL => Key::FinalMode, + VK_KANJI => Key::KanjiMode, + VK_ESCAPE => Key::Escape, + VK_NONCONVERT => Key::NonConvert, + VK_ACCEPT => Key::Accept, + VK_PRIOR => Key::PageUp, + VK_NEXT => Key::PageDown, + VK_END => Key::End, + VK_HOME => Key::Home, + VK_LEFT => Key::ArrowLeft, + VK_UP => Key::ArrowUp, + VK_RIGHT => Key::ArrowRight, + VK_DOWN => Key::ArrowDown, + VK_SELECT => Key::Select, + VK_PRINT => Key::Print, + VK_EXECUTE => Key::Execute, + VK_SNAPSHOT => Key::PrintScreen, + VK_INSERT => Key::Insert, + VK_DELETE => Key::Delete, + VK_HELP => Key::Help, + VK_LWIN | VK_RWIN => Key::Meta, + VK_APPS => Key::ContextMenu, + VK_SLEEP => Key::Standby, + VK_F1 => Key::F1, + VK_F2 => Key::F2, + VK_F3 => Key::F3, + VK_F4 => Key::F4, + VK_F5 => Key::F5, + VK_F6 => Key::F6, + VK_F7 => Key::F7, + VK_F8 => Key::F8, + VK_F9 => Key::F9, + VK_F10 => Key::F10, + VK_F11 => Key::F11, + VK_F12 => Key::F12, + VK_NUMLOCK => Key::NumLock, + VK_SCROLL => Key::ScrollLock, + VK_BROWSER_BACK => Key::BrowserBack, + VK_BROWSER_FORWARD => Key::BrowserForward, + VK_BROWSER_REFRESH => Key::BrowserRefresh, + VK_BROWSER_STOP => Key::BrowserStop, + VK_BROWSER_SEARCH => Key::BrowserSearch, + VK_BROWSER_FAVORITES => Key::BrowserFavorites, + VK_BROWSER_HOME => Key::BrowserHome, + VK_VOLUME_MUTE => Key::AudioVolumeMute, + VK_VOLUME_DOWN => Key::AudioVolumeDown, + VK_VOLUME_UP => Key::AudioVolumeUp, + VK_MEDIA_NEXT_TRACK => Key::MediaTrackNext, + VK_MEDIA_PREV_TRACK => Key::MediaTrackPrevious, + VK_MEDIA_STOP => Key::MediaStop, + VK_MEDIA_PLAY_PAUSE => Key::MediaPlayPause, + VK_LAUNCH_MAIL => Key::LaunchMail, + VK_LAUNCH_MEDIA_SELECT => Key::LaunchMediaPlayer, + VK_LAUNCH_APP1 => Key::LaunchApplication1, + VK_LAUNCH_APP2 => Key::LaunchApplication2, + VK_OEM_ATTN => Key::Alphanumeric, + VK_CONVERT => Key::Convert, + VK_MODECHANGE => Key::ModeChange, + VK_PROCESSKEY => Key::Process, + VK_ATTN => Key::Attn, + VK_CRSEL => Key::CrSel, + VK_EXSEL => Key::ExSel, + VK_EREOF => Key::EraseEof, + VK_PLAY => Key::Play, + VK_ZOOM => Key::ZoomToggle, + VK_OEM_CLEAR => Key::Clear, + _ => return None, + }) +} + +fn code_unit_to_key(code_unit: u32) -> Key { + match code_unit { + 0x8 | 0x7F => Key::Backspace, + 0x9 => Key::Tab, + 0xA | 0xD => Key::Enter, + 0x1B => Key::Escape, + _ if code_unit >= 0x20 => { + if let Some(c) = std::char::from_u32(code_unit) { + Key::Character(c.to_string()) + } else { + // UTF-16 error, very unlikely + Key::Unidentified + } + } + _ => Key::Unidentified, + } +} + +/// Get location from virtual key code. +/// +/// This logic is based on NativeKey::GetKeyLocation from Mozilla. +fn vk_to_location(vk: VkCode, is_extended: bool) -> Location { + match vk as INT { + VK_LSHIFT | VK_LCONTROL | VK_LMENU | VK_LWIN => Location::Left, + VK_RSHIFT | VK_RCONTROL | VK_RMENU | VK_RWIN => Location::Right, + VK_RETURN if is_extended => Location::Numpad, + VK_INSERT | VK_DELETE | VK_END | VK_DOWN | VK_NEXT | VK_LEFT | VK_CLEAR | VK_RIGHT + | VK_HOME | VK_UP | VK_PRIOR => { + if is_extended { + Location::Standard + } else { + Location::Numpad + } + } + VK_NUMPAD0 | VK_NUMPAD1 | VK_NUMPAD2 | VK_NUMPAD3 | VK_NUMPAD4 | VK_NUMPAD5 + | VK_NUMPAD6 | VK_NUMPAD7 | VK_NUMPAD8 | VK_NUMPAD9 | VK_DECIMAL | VK_DIVIDE + | VK_MULTIPLY | VK_SUBTRACT | VK_ADD | VK_ABNT_C2 => Location::Numpad, + _ => Location::Standard, + } +} + +impl KeyboardState { + /// Create a new keyboard state. + /// + /// There should be one of these per window. It loads the current keyboard + /// layout and retains some mapping information from it. + pub(crate) fn new() -> KeyboardState { + unsafe { + let hkl = GetKeyboardLayout(0); + let key_vals = HashMap::new(); + let dead_keys = HashSet::new(); + let stash_vk = None; + let stash_utf16 = Vec::new(); + let has_altgr = false; + let mut result = + KeyboardState { hkl, key_vals, dead_keys, has_altgr, stash_vk, stash_utf16 }; + result.load_keyboard_layout(); + result + } + } + + /// Process one message from the platform. + /// + /// This is the main interface point for generating cooked keyboard events + /// from raw platform messages. It should be called for each relevant message, + /// which comprises: `WM_KEYDOWN`, `WM_KEYUP`, `WM_CHAR`, `WM_SYSKEYDOWN`, + /// `WM_SYSKEYUP`, `WM_SYSCHAR`, and `WM_INPUTLANGCHANGE`. + /// + /// As a general theory, many keyboard events generate a sequence of platform + /// messages. In these cases, we stash information from all messages but the + /// last, and generate the event from the last (using `PeekMessage` to detect + /// that case). Mozilla handling is slightly different; it actually tries to + /// do the processing on the first message, fetching the subsequent messages + /// from the queue. We believe our handling is simpler and more robust. + /// + /// A simple example of a multi-message sequence is the key "=". In a US layout, + /// we'd expect `WM_KEYDOWN` with `wparam = VK_OEM_PLUS` and lparam encoding the + /// keycode that translates into `Code::Equal`, followed by a `WM_CHAR` with + /// `wparam = b"="` and the same scancode. + /// + /// A more complex example of a multi-message sequence is the second press of + /// that key in a German layout, where it's mapped to the dead key for accent + /// acute. Then we expect `WM_KEYDOWN` with `wparam = VK_OEM_6` followed by + /// two `WM_CHAR` with `wparam = 0xB4` (corresponding to U+00B4 = acute accent). + /// In this case, the result (produced on the final message in the sequence) is + /// a key event with `key = Key::Character("´´")`, which also matches browser + /// behavior. + /// + /// # Safety + /// + /// The `hwnd` argument must be a valid `HWND`. Similarly, the `lparam` must be + /// a valid `HKL` reference in the `WM_INPUTLANGCHANGE` message. Actual danger + /// is likely low, though. + pub(crate) unsafe fn process_message( + &mut self, hwnd: HWND, msg: UINT, wparam: WPARAM, lparam: LPARAM, + ) -> Option { + match msg { + WM_KEYDOWN | WM_SYSKEYDOWN => { + //println!("keydown wparam {:x} lparam {:x}", wparam, lparam); + let scan_code = ((lparam & SCAN_MASK) >> 16) as u32; + let vk = self.refine_vk(wparam as u8, scan_code); + if is_last_message(hwnd, msg, lparam) { + let modifiers = self.get_modifiers(); + let code = scan_to_code(scan_code); + let key = vk_to_key(vk).unwrap_or_else(|| self.get_base_key(vk, modifiers)); + let repeat = (lparam & 0x4000_0000) != 0; + let is_extended = (lparam & 0x100_0000) != 0; + let location = vk_to_location(vk, is_extended); + let state = KeyState::Down; + let event = KeyboardEvent { + state, + modifiers, + code, + key, + is_composing: false, + location, + repeat, + }; + Some(event) + } else { + self.stash_vk = Some(vk); + None + } + } + WM_KEYUP | WM_SYSKEYUP => { + let scan_code = ((lparam & SCAN_MASK) >> 16) as u32; + let vk = self.refine_vk(wparam as u8, scan_code); + let modifiers = self.get_modifiers(); + let code = scan_to_code(scan_code); + let key = vk_to_key(vk).unwrap_or_else(|| self.get_base_key(vk, modifiers)); + let repeat = false; + let is_extended = (lparam & 0x100_0000) != 0; + let location = vk_to_location(vk, is_extended); + let state = KeyState::Up; + let event = KeyboardEvent { + state, + modifiers, + code, + key, + is_composing: false, + location, + repeat, + }; + Some(event) + } + WM_CHAR | WM_SYSCHAR => { + //println!("char wparam {:x} lparam {:x}", wparam, lparam); + if is_last_message(hwnd, msg, lparam) { + let stash_vk = self.stash_vk.take(); + let modifiers = self.get_modifiers(); + let scan_code = ((lparam & SCAN_MASK) >> 16) as u32; + let vk = self.refine_vk(stash_vk.unwrap_or(0), scan_code); + let code = scan_to_code(scan_code); + let key = if self.stash_utf16.is_empty() && wparam < 0x20 { + vk_to_key(vk).unwrap_or_else(|| self.get_base_key(vk, modifiers)) + } else { + self.stash_utf16.push(wparam as u16); + if let Ok(s) = String::from_utf16(&self.stash_utf16) { + Key::Character(s) + } else { + Key::Unidentified + } + }; + self.stash_utf16.clear(); + let repeat = (lparam & 0x4000_0000) != 0; + let is_extended = (lparam & 0x100_0000) != 0; + let location = vk_to_location(vk, is_extended); + let state = KeyState::Down; + let event = KeyboardEvent { + state, + modifiers, + code, + key, + is_composing: false, + location, + repeat, + }; + Some(event) + } else { + self.stash_utf16.push(wparam as u16); + None + } + } + WM_INPUTLANGCHANGE => { + self.hkl = lparam as HKL; + self.load_keyboard_layout(); + None + } + _ => None, + } + } + + /// Get the modifier state. + /// + /// This function is designed to be called from a message handler, and + /// gives the modifier state at the time of the message (ie is the + /// synchronous variant). See [`GetKeyState`] for more context. + /// + /// The interpretation of modifiers depends on the keyboard layout, as + /// some layouts have [AltGr] and others do not. + /// + /// [`GetKeyState`]: https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getkeystate + /// [AltGr]: https://en.wikipedia.org/wiki/AltGr_key + pub(crate) fn get_modifiers(&self) -> Modifiers { + unsafe { + let mut modifiers = Modifiers::empty(); + for &(vk, modifier, mask) in MODIFIER_MAP { + if GetKeyState(vk) & mask != 0 { + modifiers |= modifier; + } + } + if self.has_altgr && GetKeyState(VK_RMENU) & 0x80 != 0 { + modifiers |= Modifiers::ALT_GRAPH; + modifiers &= !(Modifiers::CONTROL | Modifiers::ALT); + } + modifiers + } + } + + /// The same as [Self::get_modifiers()], but it reads the Ctrl and Shift state from a mouse + /// event's wParam parameter. Saves two calls to [GetKeyState()]. + pub(crate) fn get_modifiers_from_mouse_wparam(&self, wparam: WPARAM) -> Modifiers { + unsafe { + let mut modifiers = Modifiers::empty(); + for &(vk, modifier, mask) in MODIFIER_MAP { + let modifier_active = match modifier { + Modifiers::CONTROL => wparam & MK_CONTROL != 0, + Modifiers::SHIFT => wparam & MK_SHIFT != 0, + _ => GetKeyState(vk) & mask != 0, + }; + + if modifier_active { + modifiers |= modifier; + } + } + if self.has_altgr && GetKeyState(VK_RMENU) & 0x80 != 0 { + modifiers |= Modifiers::ALT_GRAPH; + modifiers &= !(Modifiers::CONTROL | Modifiers::ALT); + } + modifiers + } + } + + /// Load a keyboard layout. + /// + /// We need to retain a map of virtual key codes in various modifier + /// states, because it's not practical to query that at keyboard event + /// time (the main culprit is that `ToUnicodeEx` is stateful). + /// + /// The logic is based on Mozilla KeyboardLayout::LoadLayout but is + /// considerably simplified. + fn load_keyboard_layout(&mut self) { + unsafe { + self.key_vals.clear(); + self.dead_keys.clear(); + self.has_altgr = false; + let mut key_state = [0u8; 256]; + let mut uni_chars = [0u16; 5]; + // Right now, we're only getting the values for base and shifted + // variants. Mozilla goes through 16 mod states. + for shift_state in 0..N_SHIFT_STATE { + let has_shift = shift_state & SHIFT_STATE_SHIFT != 0; + let has_altgr = shift_state & SHIFT_STATE_ALTGR != 0; + key_state[VK_SHIFT as usize] = if has_shift { 0x80 } else { 0 }; + key_state[VK_CONTROL as usize] = if has_altgr { 0x80 } else { 0 }; + key_state[VK_LCONTROL as usize] = if has_altgr { 0x80 } else { 0 }; + key_state[VK_MENU as usize] = if has_altgr { 0x80 } else { 0 }; + key_state[VK_RMENU as usize] = if has_altgr { 0x80 } else { 0 }; + #[allow(clippy::iter_overeager_cloned)] + for vk in PRINTABLE_VKS.iter().cloned().flatten() { + let ret = ToUnicodeEx( + vk as UINT, + 0, + key_state.as_ptr(), + uni_chars.as_mut_ptr(), + uni_chars.len() as _, + 0, + self.hkl, + ); + match ret.cmp(&0) { + Ordering::Greater => { + let utf16_slice = &uni_chars[..ret as usize]; + if let Ok(strval) = String::from_utf16(utf16_slice) { + self.key_vals.insert((vk, shift_state), strval); + } + // If the AltGr version of the key has a different string than + // the base, then the layout has AltGr. Note that Mozilla also + // checks dead keys for change. + if has_altgr + && !self.has_altgr + && self.key_vals.get(&(vk, shift_state)) + != self.key_vals.get(&(vk, shift_state & !SHIFT_STATE_ALTGR)) + { + self.has_altgr = true; + } + } + Ordering::Less => { + // It's a dead key, press it again to reset the state. + self.dead_keys.insert((vk, shift_state)); + let _ = ToUnicodeEx( + vk as UINT, + 0, + key_state.as_ptr(), + uni_chars.as_mut_ptr(), + uni_chars.len() as _, + 0, + self.hkl, + ); + } + _ => (), + } + } + } + } + } + + fn get_base_key(&self, vk: VkCode, modifiers: Modifiers) -> Key { + let mut shift_state = 0; + if modifiers.contains(Modifiers::SHIFT) { + shift_state |= SHIFT_STATE_SHIFT; + } + if modifiers.contains(Modifiers::ALT_GRAPH) { + shift_state |= SHIFT_STATE_ALTGR; + } + if let Some(s) = self.key_vals.get(&(vk, shift_state)) { + Key::Character(s.clone()) + } else { + let mapped = self.map_vk(vk); + if mapped >= (1 << 31) { + Key::Dead + } else { + code_unit_to_key(mapped) + } + } + } + + /// Map a virtual key code to a code unit, also indicate if dead key. + /// + /// Bit 31 is set if the mapping is to a dead key. The bottom bits contain the code unit. + fn map_vk(&self, vk: VkCode) -> u32 { + unsafe { MapVirtualKeyExW(vk as _, MAPVK_VK_TO_CHAR, self.hkl) } + } + + /// Refine a virtual key code to distinguish left and right. + /// + /// This only does the mapping if the original code is ambiguous, as otherwise the + /// virtual key code reported in `wparam` is more reliable. + fn refine_vk(&self, vk: VkCode, mut scan_code: u32) -> VkCode { + match vk as INT { + 0 | VK_SHIFT | VK_CONTROL | VK_MENU => { + if scan_code >= 0x100 { + scan_code += 0xE000 - 0x100; + } + unsafe { MapVirtualKeyExW(scan_code, MAPVK_VSC_TO_VK_EX, self.hkl) as u8 } + } + _ => vk, + } + } +} diff --git a/crates/baseview/src/win/mod.rs b/crates/baseview/src/win/mod.rs new file mode 100644 index 0000000..b914b08 --- /dev/null +++ b/crates/baseview/src/win/mod.rs @@ -0,0 +1,7 @@ +mod cursor; +mod drop_target; +mod hook; +mod keyboard; +mod window; + +pub use window::*; diff --git a/crates/baseview/src/win/window.rs b/crates/baseview/src/win/window.rs new file mode 100644 index 0000000..51619d0 --- /dev/null +++ b/crates/baseview/src/win/window.rs @@ -0,0 +1,856 @@ +use winapi::shared::guiddef::GUID; +use winapi::shared::minwindef::{ATOM, FALSE, LOWORD, LPARAM, LRESULT, UINT, WPARAM}; +use winapi::shared::windef::{HWND, RECT}; +use winapi::um::combaseapi::CoCreateGuid; +use winapi::um::ole2::{OleInitialize, RegisterDragDrop, RevokeDragDrop}; +use winapi::um::oleidl::LPDROPTARGET; +use winapi::um::winuser::{ + AdjustWindowRectEx, CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, + GetDpiForWindow, GetFocus, GetMessageW, GetWindowLongPtrW, LoadCursorW, PostMessageW, + RegisterClassW, ReleaseCapture, SetCapture, SetCursor, SetFocus, SetProcessDpiAwarenessContext, + SetTimer, SetWindowLongPtrW, SetWindowPos, TrackMouseEvent, TranslateMessage, UnregisterClassW, + CS_OWNDC, GET_XBUTTON_WPARAM, GWLP_USERDATA, HTCLIENT, IDC_ARROW, MSG, SWP_NOMOVE, + SWP_NOZORDER, TRACKMOUSEEVENT, WHEEL_DELTA, WM_CHAR, WM_CLOSE, WM_CREATE, WM_DPICHANGED, + WM_INPUTLANGCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, + WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSELEAVE, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCDESTROY, + WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SHOWWINDOW, WM_SIZE, WM_SYSCHAR, WM_SYSKEYDOWN, + WM_SYSKEYUP, WM_TIMER, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW, WS_CAPTION, WS_CHILD, + WS_CLIPSIBLINGS, WS_MAXIMIZEBOX, WS_MINIMIZEBOX, WS_POPUPWINDOW, WS_SIZEBOX, WS_VISIBLE, + XBUTTON1, XBUTTON2, +}; + +use std::cell::{Cell, Ref, RefCell, RefMut}; +use std::collections::VecDeque; +use std::ffi::{c_void, OsStr}; +use std::os::windows::ffi::OsStrExt; +use std::ptr::null_mut; +use std::rc::Rc; + +use raw_window_handle::{ + HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle, Win32WindowHandle, + WindowsDisplayHandle, +}; + +const BV_WINDOW_MUST_CLOSE: UINT = WM_USER + 1; + +use crate::win::hook::{self, KeyboardHookHandle}; +use crate::{ + Event, MouseButton, MouseCursor, MouseEvent, PhyPoint, PhySize, ScrollDelta, Size, WindowEvent, + WindowHandler, WindowInfo, WindowOpenOptions, WindowScalePolicy, +}; + +use super::cursor::cursor_to_lpcwstr; +use super::drop_target::DropTarget; +use super::keyboard::KeyboardState; + +#[cfg(feature = "opengl")] +use crate::gl::GlContext; + +unsafe fn generate_guid() -> String { + let mut guid: GUID = std::mem::zeroed(); + CoCreateGuid(&mut guid); + format!( + "{:0X}-{:0X}-{:0X}-{:0X}{:0X}-{:0X}{:0X}{:0X}{:0X}{:0X}{:0X}\0", + guid.Data1, + guid.Data2, + guid.Data3, + guid.Data4[0], + guid.Data4[1], + guid.Data4[2], + guid.Data4[3], + guid.Data4[4], + guid.Data4[5], + guid.Data4[6], + guid.Data4[7] + ) +} + +const WIN_FRAME_TIMER: usize = 4242; + +pub struct WindowHandle { + hwnd: Option, + is_open: Rc>, +} + +impl WindowHandle { + pub fn close(&mut self) { + if let Some(hwnd) = self.hwnd.take() { + unsafe { + PostMessageW(hwnd, BV_WINDOW_MUST_CLOSE, 0, 0); + } + } + } + + pub fn is_open(&self) -> bool { + self.is_open.get() + } +} + +unsafe impl HasRawWindowHandle for WindowHandle { + fn raw_window_handle(&self) -> RawWindowHandle { + if let Some(hwnd) = self.hwnd { + let mut handle = Win32WindowHandle::empty(); + handle.hwnd = hwnd as *mut c_void; + + RawWindowHandle::Win32(handle) + } else { + RawWindowHandle::Win32(Win32WindowHandle::empty()) + } + } +} + +struct ParentHandle { + is_open: Rc>, +} + +impl ParentHandle { + pub fn new(hwnd: HWND) -> (Self, WindowHandle) { + let is_open = Rc::new(Cell::new(true)); + + let handle = WindowHandle { hwnd: Some(hwnd), is_open: Rc::clone(&is_open) }; + + (Self { is_open }, handle) + } +} + +impl Drop for ParentHandle { + fn drop(&mut self) { + self.is_open.set(false); + } +} + +pub(crate) unsafe extern "system" fn wnd_proc( + hwnd: HWND, msg: UINT, wparam: WPARAM, lparam: LPARAM, +) -> LRESULT { + if msg == WM_CREATE { + PostMessageW(hwnd, WM_SHOWWINDOW, 0, 0); + return 0; + } + + let window_state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut WindowState; + if !window_state_ptr.is_null() { + let result = wnd_proc_inner(hwnd, msg, wparam, lparam, &*window_state_ptr); + + // If any of the above event handlers caused tasks to be pushed to the deferred tasks list, + // then we'll try to handle them now + loop { + // NOTE: This is written like this instead of using a `while let` loop to avoid exending + // the borrow of `window_state.deferred_tasks` into the call of + // `window_state.handle_deferred_task()` since that may also generate additional + // messages. + let task = match (*window_state_ptr).deferred_tasks.borrow_mut().pop_front() { + Some(task) => task, + None => break, + }; + + (*window_state_ptr).handle_deferred_task(task); + } + + // NOTE: This is not handled in `wnd_proc_inner` because of the deferred task loop above + if msg == WM_NCDESTROY { + RevokeDragDrop(hwnd); + unregister_wnd_class((*window_state_ptr).window_class); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + drop(Rc::from_raw(window_state_ptr)); + } + + // The actual custom window proc has been moved to another function so we can always handle + // the deferred tasks regardless of whether the custom window proc returns early or not + if let Some(result) = result { + return result; + } + } + + DefWindowProcW(hwnd, msg, wparam, lparam) +} + +/// Our custom `wnd_proc` handler. If the result contains a value, then this is returned after +/// handling any deferred tasks. otherwise the default window procedure is invoked. +unsafe fn wnd_proc_inner( + hwnd: HWND, msg: UINT, wparam: WPARAM, lparam: LPARAM, window_state: &WindowState, +) -> Option { + match msg { + WM_MOUSEMOVE => { + let mut window = crate::Window::new(window_state.create_window()); + + let mut mouse_was_outside_window = window_state.mouse_was_outside_window.borrow_mut(); + if *mouse_was_outside_window { + // this makes Windows track whether the mouse leaves the window. + // When the mouse leaves it results in a `WM_MOUSELEAVE` event. + let mut track_mouse = TRACKMOUSEEVENT { + cbSize: std::mem::size_of::() as u32, + dwFlags: winapi::um::winuser::TME_LEAVE, + hwndTrack: hwnd, + dwHoverTime: winapi::um::winuser::HOVER_DEFAULT, + }; + // Couldn't find a good way to track whether the mouse enters, + // but if `WM_MOUSEMOVE` happens, the mouse must have entered. + TrackMouseEvent(&mut track_mouse); + *mouse_was_outside_window = false; + + let enter_event = Event::Mouse(MouseEvent::CursorEntered); + window_state + .handler + .borrow_mut() + .as_mut() + .unwrap() + .on_event(&mut window, enter_event); + } + + let x = (lparam & 0xFFFF) as i16 as i32; + let y = ((lparam >> 16) & 0xFFFF) as i16 as i32; + + let physical_pos = PhyPoint { x, y }; + let logical_pos = physical_pos.to_logical(&window_state.window_info.borrow()); + let move_event = Event::Mouse(MouseEvent::CursorMoved { + position: logical_pos, + modifiers: window_state + .keyboard_state + .borrow() + .get_modifiers_from_mouse_wparam(wparam), + }); + window_state.handler.borrow_mut().as_mut().unwrap().on_event(&mut window, move_event); + Some(0) + } + + WM_MOUSELEAVE => { + let mut window = crate::Window::new(window_state.create_window()); + let event = Event::Mouse(MouseEvent::CursorLeft); + window_state.handler.borrow_mut().as_mut().unwrap().on_event(&mut window, event); + + *window_state.mouse_was_outside_window.borrow_mut() = true; + Some(0) + } + WM_MOUSEWHEEL | WM_MOUSEHWHEEL => { + let mut window = crate::Window::new(window_state.create_window()); + + let value = (wparam >> 16) as i16; + let value = value as i32; + let value = value as f32 / WHEEL_DELTA as f32; + + let event = Event::Mouse(MouseEvent::WheelScrolled { + delta: if msg == WM_MOUSEWHEEL { + ScrollDelta::Lines { x: 0.0, y: value } + } else { + ScrollDelta::Lines { x: value, y: 0.0 } + }, + modifiers: window_state + .keyboard_state + .borrow() + .get_modifiers_from_mouse_wparam(wparam), + }); + + window_state.handler.borrow_mut().as_mut().unwrap().on_event(&mut window, event); + + Some(0) + } + WM_LBUTTONDOWN | WM_LBUTTONUP | WM_MBUTTONDOWN | WM_MBUTTONUP | WM_RBUTTONDOWN + | WM_RBUTTONUP | WM_XBUTTONDOWN | WM_XBUTTONUP => { + let mut window = crate::Window::new(window_state.create_window()); + + let mut mouse_button_counter = window_state.mouse_button_counter.get(); + + let button = match msg { + WM_LBUTTONDOWN | WM_LBUTTONUP => Some(MouseButton::Left), + WM_MBUTTONDOWN | WM_MBUTTONUP => Some(MouseButton::Middle), + WM_RBUTTONDOWN | WM_RBUTTONUP => Some(MouseButton::Right), + WM_XBUTTONDOWN | WM_XBUTTONUP => match GET_XBUTTON_WPARAM(wparam) { + XBUTTON1 => Some(MouseButton::Back), + XBUTTON2 => Some(MouseButton::Forward), + _ => None, + }, + _ => None, + }; + + if let Some(button) = button { + let event = match msg { + WM_LBUTTONDOWN | WM_MBUTTONDOWN | WM_RBUTTONDOWN | WM_XBUTTONDOWN => { + // Capture the mouse cursor on button down + mouse_button_counter = mouse_button_counter.saturating_add(1); + SetCapture(hwnd); + MouseEvent::ButtonPressed { + button, + modifiers: window_state + .keyboard_state + .borrow() + .get_modifiers_from_mouse_wparam(wparam), + } + } + WM_LBUTTONUP | WM_MBUTTONUP | WM_RBUTTONUP | WM_XBUTTONUP => { + // Release the mouse cursor capture when all buttons are released + mouse_button_counter = mouse_button_counter.saturating_sub(1); + if mouse_button_counter == 0 { + ReleaseCapture(); + } + + MouseEvent::ButtonReleased { + button, + modifiers: window_state + .keyboard_state + .borrow() + .get_modifiers_from_mouse_wparam(wparam), + } + } + _ => { + unreachable!() + } + }; + + window_state.mouse_button_counter.set(mouse_button_counter); + + window_state + .handler + .borrow_mut() + .as_mut() + .unwrap() + .on_event(&mut window, Event::Mouse(event)); + } + + None + } + WM_TIMER => { + let mut window = crate::Window::new(window_state.create_window()); + + if wparam == WIN_FRAME_TIMER { + window_state.handler.borrow_mut().as_mut().unwrap().on_frame(&mut window); + } + + Some(0) + } + WM_CLOSE => { + // Make sure to release the borrow before the DefWindowProc call + { + let mut window = crate::Window::new(window_state.create_window()); + + window_state + .handler + .borrow_mut() + .as_mut() + .unwrap() + .on_event(&mut window, Event::Window(WindowEvent::WillClose)); + } + + // DestroyWindow(hwnd); + // Some(0) + Some(DefWindowProcW(hwnd, msg, wparam, lparam)) + } + WM_CHAR | WM_SYSCHAR | WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP + | WM_INPUTLANGCHANGE => { + let mut window = crate::Window::new(window_state.create_window()); + + let opt_event = + window_state.keyboard_state.borrow_mut().process_message(hwnd, msg, wparam, lparam); + + if let Some(event) = opt_event { + window_state + .handler + .borrow_mut() + .as_mut() + .unwrap() + .on_event(&mut window, Event::Keyboard(event)); + } + + if msg != WM_SYSKEYDOWN { + Some(0) + } else { + None + } + } + WM_SIZE => { + let mut window = crate::Window::new(window_state.create_window()); + + let width = (lparam & 0xFFFF) as u16 as u32; + let height = ((lparam >> 16) & 0xFFFF) as u16 as u32; + + let new_window_info = { + let mut window_info = window_state.window_info.borrow_mut(); + let new_window_info = + WindowInfo::from_physical_size(PhySize { width, height }, window_info.scale()); + + // Only send the event if anything changed + if window_info.physical_size() == new_window_info.physical_size() { + return None; + } + + *window_info = new_window_info; + + new_window_info + }; + + window_state + .handler + .borrow_mut() + .as_mut() + .unwrap() + .on_event(&mut window, Event::Window(WindowEvent::Resized(new_window_info))); + + None + } + WM_DPICHANGED => { + // To avoid weirdness with the realtime borrow checker. + let new_rect = { + if let WindowScalePolicy::SystemScaleFactor = window_state.scale_policy { + let dpi = (wparam & 0xFFFF) as u16 as u32; + let scale_factor = dpi as f64 / 96.0; + + let mut window_info = window_state.window_info.borrow_mut(); + *window_info = + WindowInfo::from_logical_size(window_info.logical_size(), scale_factor); + + Some(( + RECT { + left: 0, + top: 0, + // todo: check if usize fits into i32 + right: window_info.physical_size().width as i32, + bottom: window_info.physical_size().height as i32, + }, + window_state.dw_style, + )) + } else { + None + } + }; + if let Some((mut new_rect, dw_style)) = new_rect { + // Convert this desired "client rectangle" size to the actual "window rectangle" + // size (Because of course you have to do that). + AdjustWindowRectEx(&mut new_rect, dw_style, 0, 0); + + // Windows makes us resize the window manually. This will trigger another `WM_SIZE` event, + // which we can then send the user the new scale factor. + SetWindowPos( + hwnd, + hwnd, + new_rect.left, + new_rect.top, + new_rect.right - new_rect.left, + new_rect.bottom - new_rect.top, + SWP_NOZORDER | SWP_NOMOVE, + ); + } + + None + } + // If WM_SETCURSOR returns `None`, WM_SETCURSOR continues to get handled by the outer window(s), + // If it returns `Some(1)`, the current window decides what the cursor is + WM_SETCURSOR => { + let low_word = LOWORD(lparam as u32) as isize; + let mouse_in_window = low_word == HTCLIENT; + if mouse_in_window { + // Here we need to set the cursor back to what the state says, since it can have changed when outside the window + let cursor = + LoadCursorW(null_mut(), cursor_to_lpcwstr(window_state.cursor_icon.get())); + unsafe { + SetCursor(cursor); + } + Some(1) + } else { + // Cursor is being changed by some other window, e.g. when having mouse on the borders to resize it + None + } + } + // NOTE: `WM_NCDESTROY` is handled in the outer function because this deallocates the window + // state + BV_WINDOW_MUST_CLOSE => { + DestroyWindow(hwnd); + Some(0) + } + _ => None, + } +} + +unsafe fn register_wnd_class() -> ATOM { + // We generate a unique name for the new window class to prevent name collisions + let class_name_str = format!("Baseview-{}", generate_guid()); + let mut class_name: Vec = OsStr::new(&class_name_str).encode_wide().collect(); + class_name.push(0); + + let wnd_class = WNDCLASSW { + style: CS_OWNDC, + lpfnWndProc: Some(wnd_proc), + hInstance: null_mut(), + lpszClassName: class_name.as_ptr(), + cbClsExtra: 0, + cbWndExtra: 0, + hIcon: null_mut(), + hCursor: LoadCursorW(null_mut(), IDC_ARROW), + hbrBackground: null_mut(), + lpszMenuName: null_mut(), + }; + + RegisterClassW(&wnd_class) +} + +unsafe fn unregister_wnd_class(wnd_class: ATOM) { + UnregisterClassW(wnd_class as _, null_mut()); +} + +/// All data associated with the window. This uses internal mutability so the outer struct doesn't +/// need to be mutably borrowed. Mutably borrowing the entire `WindowState` can be problematic +/// because of the Windows message loops' reentrant nature. Care still needs to be taken to prevent +/// `handler` from indirectly triggering other events that would also need to be handled using +/// `handler`. +pub(super) struct WindowState { + /// The HWND belonging to this window. The window's actual state is stored in the `WindowState` + /// struct associated with this HWND through `unsafe { GetWindowLongPtrW(self.hwnd, + /// GWLP_USERDATA) } as *const WindowState`. + pub hwnd: HWND, + window_class: ATOM, + window_info: RefCell, + _parent_handle: Option, + keyboard_state: RefCell, + mouse_button_counter: Cell, + mouse_was_outside_window: RefCell, + cursor_icon: Cell, + // Initialized late so the `Window` can hold a reference to this `WindowState` + handler: RefCell>>, + _drop_target: RefCell>>, + scale_policy: WindowScalePolicy, + dw_style: u32, + + // handle to the win32 keyboard hook + // we don't need to read from this, just carry it around so the Drop impl can run + #[allow(dead_code)] + kb_hook: KeyboardHookHandle, + + /// Tasks that should be executed at the end of `wnd_proc`. This is needed to avoid mutably + /// borrowing the fields from `WindowState` more than once. For instance, when the window + /// handler requests a resize in response to a keyboard event, the window state will already be + /// borrowed in `wnd_proc`. So the `resize()` function below cannot also mutably borrow that + /// window state at the same time. + pub deferred_tasks: RefCell>, + + #[cfg(feature = "opengl")] + pub gl_context: Option, +} + +impl WindowState { + pub(super) fn create_window(&self) -> Window<'_> { + Window { state: self } + } + + pub(super) fn window_info(&self) -> Ref<'_, WindowInfo> { + self.window_info.borrow() + } + + pub(super) fn keyboard_state(&self) -> Ref<'_, KeyboardState> { + self.keyboard_state.borrow() + } + + pub(super) fn handler_mut(&self) -> RefMut<'_, Option>> { + self.handler.borrow_mut() + } + + /// Handle a deferred task as described in [`Self::deferred_tasks`]. + pub(self) fn handle_deferred_task(&self, task: WindowTask) { + match task { + WindowTask::Resize(size) => { + // `self.window_info` will be modified in response to the `WM_SIZE` event that + // follows the `SetWindowPos()` call + let scaling = self.window_info.borrow().scale(); + let window_info = WindowInfo::from_logical_size(size, scaling); + + // If the window is a standalone window then the size needs to include the window + // decorations + let mut rect = RECT { + left: 0, + top: 0, + right: window_info.physical_size().width as i32, + bottom: window_info.physical_size().height as i32, + }; + unsafe { + AdjustWindowRectEx(&mut rect, self.dw_style, 0, 0); + SetWindowPos( + self.hwnd, + self.hwnd, + 0, + 0, + rect.right - rect.left, + rect.bottom - rect.top, + SWP_NOZORDER | SWP_NOMOVE, + ) + }; + } + } + } +} + +/// Tasks that must be deferred until the end of [`wnd_proc()`] to avoid reentrant `WindowState` +/// borrows. See the docstring on [`WindowState::deferred_tasks`] for more information. +#[derive(Debug, Clone)] +pub(super) enum WindowTask { + /// Resize the window to the given size. The size is in logical pixels. DPI scaling is applied + /// automatically. + Resize(Size), +} + +pub struct Window<'a> { + state: &'a WindowState, +} + +impl Window<'_> { + pub fn open_parented(parent: &P, options: WindowOpenOptions, build: B) -> WindowHandle + where + P: HasRawWindowHandle, + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + let parent = match parent.raw_window_handle() { + RawWindowHandle::Win32(h) => h.hwnd as HWND, + h => panic!("unsupported parent handle {:?}", h), + }; + + let (window_handle, _) = Self::open(true, parent, options, build); + + window_handle + } + + pub fn open_blocking(options: WindowOpenOptions, build: B) + where + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + let (_, hwnd) = Self::open(false, null_mut(), options, build); + + unsafe { + let mut msg: MSG = std::mem::zeroed(); + + loop { + let status = GetMessageW(&mut msg, hwnd, 0, 0); + + if status == -1 { + break; + } + + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } + + fn open( + parented: bool, parent: HWND, options: WindowOpenOptions, build: B, + ) -> (WindowHandle, HWND) + where + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + unsafe { + let mut title: Vec = OsStr::new(&options.title[..]).encode_wide().collect(); + title.push(0); + + let window_class = register_wnd_class(); + // todo: manage error ^ + + let scaling = match options.scale { + WindowScalePolicy::SystemScaleFactor => 1.0, + WindowScalePolicy::ScaleFactor(scale) => scale, + }; + + let window_info = WindowInfo::from_logical_size(options.size, scaling); + + let mut rect = RECT { + left: 0, + top: 0, + // todo: check if usize fits into i32 + right: window_info.physical_size().width as i32, + bottom: window_info.physical_size().height as i32, + }; + + let flags = if parented { + WS_CHILD | WS_VISIBLE + } else { + WS_POPUPWINDOW + | WS_CAPTION + | WS_VISIBLE + | WS_SIZEBOX + | WS_MINIMIZEBOX + | WS_MAXIMIZEBOX + | WS_CLIPSIBLINGS + }; + + if !parented { + AdjustWindowRectEx(&mut rect, flags, FALSE, 0); + } + + let hwnd = CreateWindowExW( + 0, + window_class as _, + title.as_ptr(), + flags, + 0, + 0, + rect.right - rect.left, + rect.bottom - rect.top, + parent as *mut _, + null_mut(), + null_mut(), + null_mut(), + ); + // todo: manage error ^ + + let kb_hook = hook::init_keyboard_hook(hwnd); + + #[cfg(feature = "opengl")] + let gl_context: Option = options.gl_config.map(|gl_config| { + let mut handle = Win32WindowHandle::empty(); + handle.hwnd = hwnd as *mut c_void; + let handle = RawWindowHandle::Win32(handle); + + GlContext::create(&handle, gl_config).expect("Could not create OpenGL context") + }); + + let (parent_handle, window_handle) = ParentHandle::new(hwnd); + let parent_handle = if parented { Some(parent_handle) } else { None }; + + let window_state = Rc::new(WindowState { + hwnd, + window_class, + window_info: RefCell::new(window_info), + _parent_handle: parent_handle, + keyboard_state: RefCell::new(KeyboardState::new()), + mouse_button_counter: Cell::new(0), + mouse_was_outside_window: RefCell::new(true), + cursor_icon: Cell::new(MouseCursor::Default), + // The Window refers to this `WindowState`, so this `handler` needs to be + // initialized later + handler: RefCell::new(None), + _drop_target: RefCell::new(None), + scale_policy: options.scale, + dw_style: flags, + + deferred_tasks: RefCell::new(VecDeque::with_capacity(4)), + + kb_hook, + + #[cfg(feature = "opengl")] + gl_context, + }); + + let handler = { + let mut window = crate::Window::new(window_state.create_window()); + + build(&mut window) + }; + *window_state.handler.borrow_mut() = Some(Box::new(handler)); + + // Only works on Windows 10 unfortunately. + SetProcessDpiAwarenessContext( + winapi::shared::windef::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE, + ); + + // Now we can get the actual dpi of the window. + let new_rect = if let WindowScalePolicy::SystemScaleFactor = options.scale { + // Only works on Windows 10 unfortunately. + let dpi = GetDpiForWindow(hwnd); + let scale_factor = dpi as f64 / 96.0; + + let mut window_info = window_state.window_info.borrow_mut(); + if window_info.scale() != scale_factor { + *window_info = + WindowInfo::from_logical_size(window_info.logical_size(), scale_factor); + + Some(RECT { + left: 0, + top: 0, + // todo: check if usize fits into i32 + right: window_info.physical_size().width as i32, + bottom: window_info.physical_size().height as i32, + }) + } else { + None + } + } else { + None + }; + + let drop_target = Rc::new(DropTarget::new(Rc::downgrade(&window_state))); + *window_state._drop_target.borrow_mut() = Some(drop_target.clone()); + + OleInitialize(null_mut()); + RegisterDragDrop(hwnd, Rc::as_ptr(&drop_target) as LPDROPTARGET); + + SetWindowLongPtrW(hwnd, GWLP_USERDATA, Rc::into_raw(window_state) as *const _ as _); + SetTimer(hwnd, WIN_FRAME_TIMER, 15, None); + + if let Some(mut new_rect) = new_rect { + // Convert this desired"client rectangle" size to the actual "window rectangle" + // size (Because of course you have to do that). + AdjustWindowRectEx(&mut new_rect, flags, 0, 0); + + // Windows makes us resize the window manually. This will trigger another `WM_SIZE` event, + // which we can then send the user the new scale factor. + SetWindowPos( + hwnd, + hwnd, + new_rect.left, + new_rect.top, + new_rect.right - new_rect.left, + new_rect.bottom - new_rect.top, + SWP_NOZORDER | SWP_NOMOVE, + ); + } + + (window_handle, hwnd) + } + } + + pub fn close(&mut self) { + unsafe { + PostMessageW(self.state.hwnd, BV_WINDOW_MUST_CLOSE, 0, 0); + } + } + + pub fn has_focus(&mut self) -> bool { + let focused_window = unsafe { GetFocus() }; + focused_window == self.state.hwnd + } + + pub fn focus(&mut self) { + unsafe { + SetFocus(self.state.hwnd); + } + } + + pub fn resize(&mut self, size: Size) { + // To avoid reentrant event handler calls we'll defer the actual resizing until after the + // event has been handled + let task = WindowTask::Resize(size); + self.state.deferred_tasks.borrow_mut().push_back(task); + } + + pub fn set_mouse_cursor(&mut self, mouse_cursor: MouseCursor) { + self.state.cursor_icon.set(mouse_cursor); + unsafe { + let cursor = LoadCursorW(null_mut(), cursor_to_lpcwstr(mouse_cursor)); + SetCursor(cursor); + } + } + + #[cfg(feature = "opengl")] + pub fn gl_context(&self) -> Option<&GlContext> { + self.state.gl_context.as_ref() + } +} + +unsafe impl HasRawWindowHandle for Window<'_> { + fn raw_window_handle(&self) -> RawWindowHandle { + let mut handle = Win32WindowHandle::empty(); + handle.hwnd = self.state.hwnd as *mut c_void; + + RawWindowHandle::Win32(handle) + } +} + +unsafe impl HasRawDisplayHandle for Window<'_> { + fn raw_display_handle(&self) -> RawDisplayHandle { + RawDisplayHandle::Windows(WindowsDisplayHandle::empty()) + } +} + +pub fn copy_to_clipboard(_data: &str) { + todo!() +} diff --git a/crates/baseview/src/window.rs b/crates/baseview/src/window.rs new file mode 100644 index 0000000..25b53d1 --- /dev/null +++ b/crates/baseview/src/window.rs @@ -0,0 +1,131 @@ +use std::marker::PhantomData; + +use raw_window_handle::{ + HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle, +}; + +use crate::event::{Event, EventStatus}; +use crate::window_open_options::WindowOpenOptions; +use crate::{MouseCursor, Size}; + +#[cfg(target_os = "macos")] +use crate::macos as platform; +#[cfg(target_os = "windows")] +use crate::win as platform; +#[cfg(target_os = "linux")] +use crate::x11 as platform; + +pub struct WindowHandle { + window_handle: platform::WindowHandle, + // so that WindowHandle is !Send on all platforms + phantom: PhantomData<*mut ()>, +} + +impl WindowHandle { + fn new(window_handle: platform::WindowHandle) -> Self { + Self { window_handle, phantom: PhantomData } + } + + /// Close the window + pub fn close(&mut self) { + self.window_handle.close(); + } + + /// Returns `true` if the window is still open, and returns `false` + /// if the window was closed/dropped. + pub fn is_open(&self) -> bool { + self.window_handle.is_open() + } +} + +unsafe impl HasRawWindowHandle for WindowHandle { + fn raw_window_handle(&self) -> RawWindowHandle { + self.window_handle.raw_window_handle() + } +} + +pub trait WindowHandler { + fn on_frame(&mut self, window: &mut Window); + fn on_event(&mut self, window: &mut Window, event: Event) -> EventStatus; +} + +pub struct Window<'a> { + window: platform::Window<'a>, + + // so that Window is !Send on all platforms + phantom: PhantomData<*mut ()>, +} + +impl<'a> Window<'a> { + #[cfg(target_os = "windows")] + pub(crate) fn new(window: platform::Window<'a>) -> Window<'a> { + Window { window, phantom: PhantomData } + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn new(window: platform::Window) -> Window { + Window { window, phantom: PhantomData } + } + + pub fn open_parented(parent: &P, options: WindowOpenOptions, build: B) -> WindowHandle + where + P: HasRawWindowHandle, + H: WindowHandler + 'static, + B: FnOnce(&mut Window) -> H, + B: Send + 'static, + { + let window_handle = platform::Window::open_parented::(parent, options, build); + WindowHandle::new(window_handle) + } + + pub fn open_blocking(options: WindowOpenOptions, build: B) + where + H: WindowHandler + 'static, + B: FnOnce(&mut Window) -> H, + B: Send + 'static, + { + platform::Window::open_blocking::(options, build) + } + + /// Close the window + pub fn close(&mut self) { + self.window.close(); + } + + /// Resize the window to the given size. The size is always in logical pixels. DPI scaling will + /// automatically be accounted for. + pub fn resize(&mut self, size: Size) { + self.window.resize(size); + } + + pub fn set_mouse_cursor(&mut self, cursor: MouseCursor) { + self.window.set_mouse_cursor(cursor); + } + + pub fn has_focus(&mut self) -> bool { + self.window.has_focus() + } + + pub fn focus(&mut self) { + self.window.focus() + } + + /// If provided, then an OpenGL context will be created for this window. You'll be able to + /// access this context through [crate::Window::gl_context]. + #[cfg(feature = "opengl")] + pub fn gl_context(&self) -> Option<&crate::gl::GlContext> { + self.window.gl_context() + } +} + +unsafe impl<'a> HasRawWindowHandle for Window<'a> { + fn raw_window_handle(&self) -> RawWindowHandle { + self.window.raw_window_handle() + } +} + +unsafe impl<'a> HasRawDisplayHandle for Window<'a> { + fn raw_display_handle(&self) -> RawDisplayHandle { + self.window.raw_display_handle() + } +} diff --git a/crates/baseview/src/window_info.rs b/crates/baseview/src/window_info.rs new file mode 100644 index 0000000..edb5701 --- /dev/null +++ b/crates/baseview/src/window_info.rs @@ -0,0 +1,144 @@ +/// The info about the window +#[derive(Debug, Copy, Clone)] +pub struct WindowInfo { + logical_size: Size, + physical_size: PhySize, + scale: f64, + scale_recip: f64, +} + +impl WindowInfo { + pub fn from_logical_size(logical_size: Size, scale: f64) -> Self { + let scale_recip = if scale == 1.0 { 1.0 } else { 1.0 / scale }; + + let physical_size = PhySize { + width: (logical_size.width * scale).round() as u32, + height: (logical_size.height * scale).round() as u32, + }; + + Self { logical_size, physical_size, scale, scale_recip } + } + + pub fn from_physical_size(physical_size: PhySize, scale: f64) -> Self { + let scale_recip = if scale == 1.0 { 1.0 } else { 1.0 / scale }; + + let logical_size = Size { + width: f64::from(physical_size.width) * scale_recip, + height: f64::from(physical_size.height) * scale_recip, + }; + + Self { logical_size, physical_size, scale, scale_recip } + } + + /// The logical size of the window + pub fn logical_size(&self) -> Size { + self.logical_size + } + + /// The physical size of the window + pub fn physical_size(&self) -> PhySize { + self.physical_size + } + + /// The scale factor of the window + pub fn scale(&self) -> f64 { + self.scale + } + + /// The reciprocal of the scale factor of the window + pub fn scale_recip(&self) -> f64 { + self.scale_recip + } +} + +/// A point in logical coordinates +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + /// Create a new point in logical coordinates + pub fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + /// Convert to actual physical coordinates + #[inline] + pub fn to_physical(&self, window_info: &WindowInfo) -> PhyPoint { + PhyPoint { + x: (self.x * window_info.scale()).round() as i32, + y: (self.y * window_info.scale()).round() as i32, + } + } +} + +/// A point in actual physical coordinates +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct PhyPoint { + pub x: i32, + pub y: i32, +} + +impl PhyPoint { + /// Create a new point in actual physical coordinates + pub fn new(x: i32, y: i32) -> Self { + Self { x, y } + } + + /// Convert to logical coordinates + #[inline] + pub fn to_logical(&self, window_info: &WindowInfo) -> Point { + Point { + x: f64::from(self.x) * window_info.scale_recip(), + y: f64::from(self.y) * window_info.scale_recip(), + } + } +} + +/// A size in logical coordinates +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Size { + pub width: f64, + pub height: f64, +} + +impl Size { + /// Create a new size in logical coordinates + pub fn new(width: f64, height: f64) -> Self { + Self { width, height } + } + + /// Convert to actual physical size + #[inline] + pub fn to_physical(&self, window_info: &WindowInfo) -> PhySize { + PhySize { + width: (self.width * window_info.scale()).round() as u32, + height: (self.height * window_info.scale()).round() as u32, + } + } +} + +/// An actual size in physical coordinates +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct PhySize { + pub width: u32, + pub height: u32, +} + +impl PhySize { + /// Create a new size in actual physical coordinates + pub fn new(width: u32, height: u32) -> Self { + Self { width, height } + } + + /// Convert to logical size + #[inline] + pub fn to_logical(&self, window_info: &WindowInfo) -> Size { + Size { + width: f64::from(self.width) * window_info.scale_recip(), + height: f64::from(self.height) * window_info.scale_recip(), + } + } +} diff --git a/crates/baseview/src/window_open_options.rs b/crates/baseview/src/window_open_options.rs new file mode 100644 index 0000000..7c5cd19 --- /dev/null +++ b/crates/baseview/src/window_open_options.rs @@ -0,0 +1,29 @@ +use crate::Size; + +/// The dpi scaling policy of the window +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WindowScalePolicy { + /// Use the system's dpi scale factor + SystemScaleFactor, + /// Use the given dpi scale factor (e.g. `1.0` = 96 dpi) + ScaleFactor(f64), +} + +/// The options for opening a new window +pub struct WindowOpenOptions { + pub title: String, + + /// The logical size of the window. + /// + /// These dimensions will be scaled by the scaling policy specified in `scale`. Mouse + /// position will be passed back as logical coordinates. + pub size: Size, + + /// The dpi scaling policy + pub scale: WindowScalePolicy, + + /// If provided, then an OpenGL context will be created for this window. You'll be able to + /// access this context through [crate::Window::gl_context]. + #[cfg(feature = "opengl")] + pub gl_config: Option, +} diff --git a/crates/baseview/src/x11/cursor.rs b/crates/baseview/src/x11/cursor.rs new file mode 100644 index 0000000..56ff0d2 --- /dev/null +++ b/crates/baseview/src/x11/cursor.rs @@ -0,0 +1,100 @@ +use std::error::Error; + +use x11rb::connection::Connection; +use x11rb::cursor::Handle as CursorHandle; +use x11rb::protocol::xproto::{ConnectionExt as _, Cursor}; +use x11rb::xcb_ffi::XCBConnection; + +use crate::MouseCursor; + +fn create_empty_cursor(conn: &XCBConnection, screen: usize) -> Result> { + let cursor_id = conn.generate_id()?; + let pixmap_id = conn.generate_id()?; + let root_window = conn.setup().roots[screen].root; + conn.create_pixmap(1, pixmap_id, root_window, 1, 1)?; + conn.create_cursor(cursor_id, pixmap_id, pixmap_id, 0, 0, 0, 0, 0, 0, 0, 0)?; + conn.free_pixmap(pixmap_id)?; + + Ok(cursor_id) +} + +fn load_cursor( + conn: &XCBConnection, cursor_handle: &CursorHandle, name: &str, +) -> Result, Box> { + let cursor = cursor_handle.load_cursor(conn, name)?; + if cursor != x11rb::NONE { + Ok(Some(cursor)) + } else { + Ok(None) + } +} + +fn load_first_existing_cursor( + conn: &XCBConnection, cursor_handle: &CursorHandle, names: &[&str], +) -> Result, Box> { + for name in names { + let cursor = load_cursor(conn, cursor_handle, name)?; + if cursor.is_some() { + return Ok(cursor); + } + } + + Ok(None) +} + +pub(super) fn get_xcursor( + conn: &XCBConnection, screen: usize, cursor_handle: &CursorHandle, cursor: MouseCursor, +) -> Result> { + let load = |name: &str| load_cursor(conn, cursor_handle, name); + let loadn = |names: &[&str]| load_first_existing_cursor(conn, cursor_handle, names); + + let cursor = match cursor { + MouseCursor::Default => None, // catch this in the fallback case below + + MouseCursor::Hand => loadn(&["hand2", "hand1"])?, + MouseCursor::HandGrabbing => loadn(&["closedhand", "grabbing"])?, + MouseCursor::Help => load("question_arrow")?, + + MouseCursor::Hidden => Some(create_empty_cursor(conn, screen)?), + + MouseCursor::Text => loadn(&["text", "xterm"])?, + MouseCursor::VerticalText => load("vertical-text")?, + + MouseCursor::Working => load("watch")?, + MouseCursor::PtrWorking => load("left_ptr_watch")?, + + MouseCursor::NotAllowed => load("crossed_circle")?, + MouseCursor::PtrNotAllowed => loadn(&["no-drop", "crossed_circle"])?, + + MouseCursor::ZoomIn => load("zoom-in")?, + MouseCursor::ZoomOut => load("zoom-out")?, + + MouseCursor::Alias => load("link")?, + MouseCursor::Copy => load("copy")?, + MouseCursor::Move => load("move")?, + MouseCursor::AllScroll => load("all-scroll")?, + MouseCursor::Cell => load("plus")?, + MouseCursor::Crosshair => load("crosshair")?, + + MouseCursor::EResize => load("right_side")?, + MouseCursor::NResize => load("top_side")?, + MouseCursor::NeResize => load("top_right_corner")?, + MouseCursor::NwResize => load("top_left_corner")?, + MouseCursor::SResize => load("bottom_side")?, + MouseCursor::SeResize => load("bottom_right_corner")?, + MouseCursor::SwResize => load("bottom_left_corner")?, + MouseCursor::WResize => load("left_side")?, + MouseCursor::EwResize => load("h_double_arrow")?, + MouseCursor::NsResize => load("v_double_arrow")?, + MouseCursor::NwseResize => loadn(&["bd_double_arrow", "size_bdiag"])?, + MouseCursor::NeswResize => loadn(&["fd_double_arrow", "size_fdiag"])?, + MouseCursor::ColResize => loadn(&["split_h", "h_double_arrow"])?, + MouseCursor::RowResize => loadn(&["split_v", "v_double_arrow"])?, + }; + + if let Some(cursor) = cursor { + Ok(cursor) + } else { + Ok(load("left_ptr")?.unwrap_or(x11rb::NONE)) + } +} diff --git a/crates/baseview/src/x11/event_loop.rs b/crates/baseview/src/x11/event_loop.rs new file mode 100644 index 0000000..53375d6 --- /dev/null +++ b/crates/baseview/src/x11/event_loop.rs @@ -0,0 +1,299 @@ +use crate::x11::keyboard::{convert_key_press_event, convert_key_release_event, key_mods}; +use crate::x11::{ParentHandle, Window, WindowInner}; +use crate::{ + Event, MouseButton, MouseEvent, PhyPoint, PhySize, ScrollDelta, WindowEvent, WindowHandler, + WindowInfo, +}; +use std::error::Error; +use std::os::fd::AsRawFd; +use std::time::{Duration, Instant}; +use x11rb::connection::Connection; +use x11rb::protocol::Event as XEvent; + +pub(super) struct EventLoop { + handler: Box, + window: WindowInner, + parent_handle: Option, + + new_physical_size: Option, + frame_interval: Duration, + event_loop_running: bool, +} + +impl EventLoop { + pub fn new( + window: WindowInner, handler: impl WindowHandler + 'static, + parent_handle: Option, + ) -> Self { + Self { + window, + handler: Box::new(handler), + parent_handle, + frame_interval: Duration::from_millis(15), + event_loop_running: false, + new_physical_size: None, + } + } + + #[inline] + fn drain_xcb_events(&mut self) -> Result<(), Box> { + // the X server has a tendency to send spurious/extraneous configure notify events when a + // window is resized, and we need to batch those together and just send one resize event + // when they've all been coalesced. + self.new_physical_size = None; + + while let Some(event) = self.window.xcb_connection.conn.poll_for_event()? { + self.handle_xcb_event(event); + } + + if let Some(size) = self.new_physical_size.take() { + self.window.window_info = + WindowInfo::from_physical_size(size, self.window.window_info.scale()); + + let window_info = self.window.window_info; + + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Window(WindowEvent::Resized(window_info)), + ); + } + + Ok(()) + } + + // Event loop + // FIXME: poll() acts fine on linux, sometimes funky on *BSD. XCB upstream uses a define to + // switch between poll() and select() (the latter of which is fine on *BSD), and we should do + // the same. + pub fn run(&mut self) -> Result<(), Box> { + use nix::poll::*; + + let xcb_fd = self.window.xcb_connection.conn.as_raw_fd(); + + let mut last_frame = Instant::now(); + self.event_loop_running = true; + + while self.event_loop_running { + // We'll try to keep a consistent frame pace. If the last frame couldn't be processed in + // the expected frame time, this will throttle down to prevent multiple frames from + // being queued up. The conditional here is needed because event handling and frame + // drawing is interleaved. The `poll()` function below will wait until the next frame + // can be drawn, or until the window receives an event. We thus need to manually check + // if it's already time to draw a new frame. + let next_frame = last_frame + self.frame_interval; + if Instant::now() >= next_frame { + self.handler.on_frame(&mut crate::Window::new(Window { inner: &self.window })); + last_frame = Instant::max(next_frame, Instant::now() - self.frame_interval); + } + + let mut fds = [PollFd::new(xcb_fd, PollFlags::POLLIN)]; + + // Check for any events in the internal buffers + // before going to sleep: + self.drain_xcb_events()?; + + // FIXME: handle errors + poll(&mut fds, next_frame.duration_since(Instant::now()).subsec_millis() as i32) + .unwrap(); + + if let Some(revents) = fds[0].revents() { + if revents.contains(PollFlags::POLLERR) { + panic!("xcb connection poll error"); + } + + if revents.contains(PollFlags::POLLIN) { + self.drain_xcb_events()?; + } + } + + // Check if the parents's handle was dropped (such as when the host + // requested the window to close) + if let Some(parent_handle) = &self.parent_handle { + if parent_handle.parent_did_drop() { + self.handle_must_close(); + self.window.close_requested.set(false); + } + } + + // Check if the user has requested the window to close + if self.window.close_requested.get() { + self.handle_must_close(); + self.window.close_requested.set(false); + } + } + + Ok(()) + } + + fn handle_xcb_event(&mut self, event: XEvent) { + // For all the keyboard and mouse events, you can fetch + // `x`, `y`, `detail`, and `state`. + // - `x` and `y` are the position inside the window where the cursor currently is + // when the event happened. + // - `detail` will tell you which keycode was pressed/released (for keyboard events) + // or which mouse button was pressed/released (for mouse events). + // For mouse events, here's what the value means (at least on my current mouse): + // 1 = left mouse button + // 2 = middle mouse button (scroll wheel) + // 3 = right mouse button + // 4 = scroll wheel up + // 5 = scroll wheel down + // 8 = lower side button ("back" button) + // 9 = upper side button ("forward" button) + // Note that you *will* get a "button released" event for even the scroll wheel + // events, which you can probably ignore. + // - `state` will tell you the state of the main three mouse buttons and some of + // the keyboard modifier keys at the time of the event. + // http://rtbo.github.io/rust-xcb/src/xcb/ffi/xproto.rs.html#445 + + match event { + //// + // window + //// + XEvent::ClientMessage(event) => { + if event.format == 32 + && event.data.as_data32()[0] + == self.window.xcb_connection.atoms.WM_DELETE_WINDOW + { + self.handle_close_requested(); + } + } + + XEvent::ConfigureNotify(event) => { + let new_physical_size = PhySize::new(event.width as u32, event.height as u32); + + if self.new_physical_size.is_some() + || new_physical_size != self.window.window_info.physical_size() + { + self.new_physical_size = Some(new_physical_size); + } + } + + //// + // mouse + //// + XEvent::MotionNotify(event) => { + let physical_pos = PhyPoint::new(event.event_x as i32, event.event_y as i32); + let logical_pos = physical_pos.to_logical(&self.window.window_info); + + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::CursorMoved { + position: logical_pos, + modifiers: key_mods(event.state), + }), + ); + } + + XEvent::EnterNotify(event) => { + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::CursorEntered), + ); + // since no `MOTION_NOTIFY` event is generated when `ENTER_NOTIFY` is generated, + // we generate a CursorMoved as well, so the mouse position from here isn't lost + let physical_pos = PhyPoint::new(event.event_x as i32, event.event_y as i32); + let logical_pos = physical_pos.to_logical(&self.window.window_info); + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::CursorMoved { + position: logical_pos, + modifiers: key_mods(event.state), + }), + ); + } + + XEvent::LeaveNotify(_) => { + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::CursorLeft), + ); + } + + XEvent::ButtonPress(event) => match event.detail { + 4..=7 => { + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::WheelScrolled { + delta: match event.detail { + 4 => ScrollDelta::Lines { x: 0.0, y: 1.0 }, + 5 => ScrollDelta::Lines { x: 0.0, y: -1.0 }, + 6 => ScrollDelta::Lines { x: -1.0, y: 0.0 }, + 7 => ScrollDelta::Lines { x: 1.0, y: 0.0 }, + _ => unreachable!(), + }, + modifiers: key_mods(event.state), + }), + ); + } + detail => { + let button_id = mouse_id(detail); + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::ButtonPressed { + button: button_id, + modifiers: key_mods(event.state), + }), + ); + } + }, + + XEvent::ButtonRelease(event) => { + if !(4..=7).contains(&event.detail) { + let button_id = mouse_id(event.detail); + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Mouse(MouseEvent::ButtonReleased { + button: button_id, + modifiers: key_mods(event.state), + }), + ); + } + } + + //// + // keys + //// + XEvent::KeyPress(event) => { + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Keyboard(convert_key_press_event(&event)), + ); + } + + XEvent::KeyRelease(event) => { + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Keyboard(convert_key_release_event(&event)), + ); + } + + _ => {} + } + } + + fn handle_close_requested(&mut self) { + // FIXME: handler should decide whether window stays open or not + self.handle_must_close(); + } + + fn handle_must_close(&mut self) { + self.handler.on_event( + &mut crate::Window::new(Window { inner: &self.window }), + Event::Window(WindowEvent::WillClose), + ); + + self.event_loop_running = false; + } +} + +fn mouse_id(id: u8) -> MouseButton { + match id { + 1 => MouseButton::Left, + 2 => MouseButton::Middle, + 3 => MouseButton::Right, + 8 => MouseButton::Back, + 9 => MouseButton::Forward, + id => MouseButton::Other(id), + } +} diff --git a/crates/baseview/src/x11/keyboard.rs b/crates/baseview/src/x11/keyboard.rs new file mode 100644 index 0000000..634a205 --- /dev/null +++ b/crates/baseview/src/x11/keyboard.rs @@ -0,0 +1,406 @@ +// Copyright 2020 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Baseview modifications to druid code: +// - collect functions from various files +// - update imports, paths etc + +//! X11 keyboard handling + +use x11rb::protocol::xproto::{KeyButMask, KeyPressEvent, KeyReleaseEvent}; + +use keyboard_types::*; + +use crate::keyboard::code_to_location; + +/// Convert a hardware scan code to a key. +/// +/// Note: this is a hardcoded layout. We need to detect the user's +/// layout from the system and apply it. +fn code_to_key(code: Code, m: Modifiers) -> Key { + fn a(s: &str) -> Key { + Key::Character(s.into()) + } + fn s(mods: Modifiers, base: &str, shifted: &str) -> Key { + if mods.contains(Modifiers::SHIFT) { + Key::Character(shifted.into()) + } else { + Key::Character(base.into()) + } + } + fn n(mods: Modifiers, base: Key, num: &str) -> Key { + if mods.contains(Modifiers::NUM_LOCK) != mods.contains(Modifiers::SHIFT) { + Key::Character(num.into()) + } else { + base + } + } + match code { + Code::KeyA => s(m, "a", "A"), + Code::KeyB => s(m, "b", "B"), + Code::KeyC => s(m, "c", "C"), + Code::KeyD => s(m, "d", "D"), + Code::KeyE => s(m, "e", "E"), + Code::KeyF => s(m, "f", "F"), + Code::KeyG => s(m, "g", "G"), + Code::KeyH => s(m, "h", "H"), + Code::KeyI => s(m, "i", "I"), + Code::KeyJ => s(m, "j", "J"), + Code::KeyK => s(m, "k", "K"), + Code::KeyL => s(m, "l", "L"), + Code::KeyM => s(m, "m", "M"), + Code::KeyN => s(m, "n", "N"), + Code::KeyO => s(m, "o", "O"), + Code::KeyP => s(m, "p", "P"), + Code::KeyQ => s(m, "q", "Q"), + Code::KeyR => s(m, "r", "R"), + Code::KeyS => s(m, "s", "S"), + Code::KeyT => s(m, "t", "T"), + Code::KeyU => s(m, "u", "U"), + Code::KeyV => s(m, "v", "V"), + Code::KeyW => s(m, "w", "W"), + Code::KeyX => s(m, "x", "X"), + Code::KeyY => s(m, "y", "Y"), + Code::KeyZ => s(m, "z", "Z"), + + Code::Digit0 => s(m, "0", ")"), + Code::Digit1 => s(m, "1", "!"), + Code::Digit2 => s(m, "2", "@"), + Code::Digit3 => s(m, "3", "#"), + Code::Digit4 => s(m, "4", "$"), + Code::Digit5 => s(m, "5", "%"), + Code::Digit6 => s(m, "6", "^"), + Code::Digit7 => s(m, "7", "&"), + Code::Digit8 => s(m, "8", "*"), + Code::Digit9 => s(m, "9", "("), + + Code::Backquote => s(m, "`", "~"), + Code::Minus => s(m, "-", "_"), + Code::Equal => s(m, "=", "+"), + Code::BracketLeft => s(m, "[", "{"), + Code::BracketRight => s(m, "]", "}"), + Code::Backslash => s(m, "\\", "|"), + Code::Semicolon => s(m, ";", ":"), + Code::Quote => s(m, "'", "\""), + Code::Comma => s(m, ",", "<"), + Code::Period => s(m, ".", ">"), + Code::Slash => s(m, "/", "?"), + + Code::Space => a(" "), + + Code::Escape => Key::Escape, + Code::Backspace => Key::Backspace, + Code::Tab => Key::Tab, + Code::Enter => Key::Enter, + Code::ControlLeft => Key::Control, + Code::ShiftLeft => Key::Shift, + Code::ShiftRight => Key::Shift, + Code::NumpadMultiply => a("*"), + Code::AltLeft => Key::Alt, + Code::CapsLock => Key::CapsLock, + Code::F1 => Key::F1, + Code::F2 => Key::F2, + Code::F3 => Key::F3, + Code::F4 => Key::F4, + Code::F5 => Key::F5, + Code::F6 => Key::F6, + Code::F7 => Key::F7, + Code::F8 => Key::F8, + Code::F9 => Key::F9, + Code::F10 => Key::F10, + Code::NumLock => Key::NumLock, + Code::ScrollLock => Key::ScrollLock, + Code::Numpad0 => n(m, Key::Insert, "0"), + Code::Numpad1 => n(m, Key::End, "1"), + Code::Numpad2 => n(m, Key::ArrowDown, "2"), + Code::Numpad3 => n(m, Key::PageDown, "3"), + Code::Numpad4 => n(m, Key::ArrowLeft, "4"), + Code::Numpad5 => n(m, Key::Clear, "5"), + Code::Numpad6 => n(m, Key::ArrowRight, "6"), + Code::Numpad7 => n(m, Key::Home, "7"), + Code::Numpad8 => n(m, Key::ArrowUp, "8"), + Code::Numpad9 => n(m, Key::PageUp, "9"), + Code::NumpadSubtract => a("-"), + Code::NumpadAdd => a("+"), + Code::NumpadDecimal => n(m, Key::Delete, "."), + Code::IntlBackslash => s(m, "\\", "|"), + Code::F11 => Key::F11, + Code::F12 => Key::F12, + // This mapping is based on the picture in the w3c spec. + Code::IntlRo => a("\\"), + Code::Convert => Key::Convert, + Code::KanaMode => Key::KanaMode, + Code::NonConvert => Key::NonConvert, + Code::NumpadEnter => Key::Enter, + Code::ControlRight => Key::Control, + Code::NumpadDivide => a("/"), + Code::PrintScreen => Key::PrintScreen, + Code::AltRight => Key::Alt, + Code::Home => Key::Home, + Code::ArrowUp => Key::ArrowUp, + Code::PageUp => Key::PageUp, + Code::ArrowLeft => Key::ArrowLeft, + Code::ArrowRight => Key::ArrowRight, + Code::End => Key::End, + Code::ArrowDown => Key::ArrowDown, + Code::PageDown => Key::PageDown, + Code::Insert => Key::Insert, + Code::Delete => Key::Delete, + Code::AudioVolumeMute => Key::AudioVolumeMute, + Code::AudioVolumeDown => Key::AudioVolumeDown, + Code::AudioVolumeUp => Key::AudioVolumeUp, + Code::NumpadEqual => a("="), + Code::Pause => Key::Pause, + Code::NumpadComma => a(","), + Code::Lang1 => Key::HangulMode, + Code::Lang2 => Key::HanjaMode, + Code::IntlYen => a("¥"), + Code::MetaLeft => Key::Meta, + Code::MetaRight => Key::Meta, + Code::ContextMenu => Key::ContextMenu, + Code::BrowserStop => Key::BrowserStop, + Code::Again => Key::Again, + Code::Props => Key::Props, + Code::Undo => Key::Undo, + Code::Select => Key::Select, + Code::Copy => Key::Copy, + Code::Open => Key::Open, + Code::Paste => Key::Paste, + Code::Find => Key::Find, + Code::Cut => Key::Cut, + Code::Help => Key::Help, + Code::LaunchApp2 => Key::LaunchApplication2, + Code::WakeUp => Key::WakeUp, + Code::LaunchApp1 => Key::LaunchApplication1, + Code::LaunchMail => Key::LaunchMail, + Code::BrowserFavorites => Key::BrowserFavorites, + Code::BrowserBack => Key::BrowserBack, + Code::BrowserForward => Key::BrowserForward, + Code::Eject => Key::Eject, + Code::MediaTrackNext => Key::MediaTrackNext, + Code::MediaPlayPause => Key::MediaPlayPause, + Code::MediaTrackPrevious => Key::MediaTrackPrevious, + Code::MediaStop => Key::MediaStop, + Code::MediaSelect => Key::LaunchMediaPlayer, + Code::BrowserHome => Key::BrowserHome, + Code::BrowserRefresh => Key::BrowserRefresh, + Code::BrowserSearch => Key::BrowserSearch, + + _ => Key::Unidentified, + } +} + +#[cfg(target_os = "linux")] +/// Map hardware keycode to code. +/// +/// In theory, the hardware keycode is device dependent, but in +/// practice it's probably pretty reliable. +/// +/// The logic is based on NativeKeyToDOMCodeName.h in Mozilla. +fn hardware_keycode_to_code(hw_keycode: u16) -> Code { + match hw_keycode { + 0x0009 => Code::Escape, + 0x000A => Code::Digit1, + 0x000B => Code::Digit2, + 0x000C => Code::Digit3, + 0x000D => Code::Digit4, + 0x000E => Code::Digit5, + 0x000F => Code::Digit6, + 0x0010 => Code::Digit7, + 0x0011 => Code::Digit8, + 0x0012 => Code::Digit9, + 0x0013 => Code::Digit0, + 0x0014 => Code::Minus, + 0x0015 => Code::Equal, + 0x0016 => Code::Backspace, + 0x0017 => Code::Tab, + 0x0018 => Code::KeyQ, + 0x0019 => Code::KeyW, + 0x001A => Code::KeyE, + 0x001B => Code::KeyR, + 0x001C => Code::KeyT, + 0x001D => Code::KeyY, + 0x001E => Code::KeyU, + 0x001F => Code::KeyI, + 0x0020 => Code::KeyO, + 0x0021 => Code::KeyP, + 0x0022 => Code::BracketLeft, + 0x0023 => Code::BracketRight, + 0x0024 => Code::Enter, + 0x0025 => Code::ControlLeft, + 0x0026 => Code::KeyA, + 0x0027 => Code::KeyS, + 0x0028 => Code::KeyD, + 0x0029 => Code::KeyF, + 0x002A => Code::KeyG, + 0x002B => Code::KeyH, + 0x002C => Code::KeyJ, + 0x002D => Code::KeyK, + 0x002E => Code::KeyL, + 0x002F => Code::Semicolon, + 0x0030 => Code::Quote, + 0x0031 => Code::Backquote, + 0x0032 => Code::ShiftLeft, + 0x0033 => Code::Backslash, + 0x0034 => Code::KeyZ, + 0x0035 => Code::KeyX, + 0x0036 => Code::KeyC, + 0x0037 => Code::KeyV, + 0x0038 => Code::KeyB, + 0x0039 => Code::KeyN, + 0x003A => Code::KeyM, + 0x003B => Code::Comma, + 0x003C => Code::Period, + 0x003D => Code::Slash, + 0x003E => Code::ShiftRight, + 0x003F => Code::NumpadMultiply, + 0x0040 => Code::AltLeft, + 0x0041 => Code::Space, + 0x0042 => Code::CapsLock, + 0x0043 => Code::F1, + 0x0044 => Code::F2, + 0x0045 => Code::F3, + 0x0046 => Code::F4, + 0x0047 => Code::F5, + 0x0048 => Code::F6, + 0x0049 => Code::F7, + 0x004A => Code::F8, + 0x004B => Code::F9, + 0x004C => Code::F10, + 0x004D => Code::NumLock, + 0x004E => Code::ScrollLock, + 0x004F => Code::Numpad7, + 0x0050 => Code::Numpad8, + 0x0051 => Code::Numpad9, + 0x0052 => Code::NumpadSubtract, + 0x0053 => Code::Numpad4, + 0x0054 => Code::Numpad5, + 0x0055 => Code::Numpad6, + 0x0056 => Code::NumpadAdd, + 0x0057 => Code::Numpad1, + 0x0058 => Code::Numpad2, + 0x0059 => Code::Numpad3, + 0x005A => Code::Numpad0, + 0x005B => Code::NumpadDecimal, + 0x005E => Code::IntlBackslash, + 0x005F => Code::F11, + 0x0060 => Code::F12, + 0x0061 => Code::IntlRo, + 0x0064 => Code::Convert, + 0x0065 => Code::KanaMode, + 0x0066 => Code::NonConvert, + 0x0068 => Code::NumpadEnter, + 0x0069 => Code::ControlRight, + 0x006A => Code::NumpadDivide, + 0x006B => Code::PrintScreen, + 0x006C => Code::AltRight, + 0x006E => Code::Home, + 0x006F => Code::ArrowUp, + 0x0070 => Code::PageUp, + 0x0071 => Code::ArrowLeft, + 0x0072 => Code::ArrowRight, + 0x0073 => Code::End, + 0x0074 => Code::ArrowDown, + 0x0075 => Code::PageDown, + 0x0076 => Code::Insert, + 0x0077 => Code::Delete, + 0x0079 => Code::AudioVolumeMute, + 0x007A => Code::AudioVolumeDown, + 0x007B => Code::AudioVolumeUp, + 0x007D => Code::NumpadEqual, + 0x007F => Code::Pause, + 0x0081 => Code::NumpadComma, + 0x0082 => Code::Lang1, + 0x0083 => Code::Lang2, + 0x0084 => Code::IntlYen, + 0x0085 => Code::MetaLeft, + 0x0086 => Code::MetaRight, + 0x0087 => Code::ContextMenu, + 0x0088 => Code::BrowserStop, + 0x0089 => Code::Again, + 0x008A => Code::Props, + 0x008B => Code::Undo, + 0x008C => Code::Select, + 0x008D => Code::Copy, + 0x008E => Code::Open, + 0x008F => Code::Paste, + 0x0090 => Code::Find, + 0x0091 => Code::Cut, + 0x0092 => Code::Help, + 0x0094 => Code::LaunchApp2, + 0x0097 => Code::WakeUp, + 0x0098 => Code::LaunchApp1, + // key to right of volume controls on T430s produces 0x9C + // but no documentation of what it should map to :/ + 0x00A3 => Code::LaunchMail, + 0x00A4 => Code::BrowserFavorites, + 0x00A6 => Code::BrowserBack, + 0x00A7 => Code::BrowserForward, + 0x00A9 => Code::Eject, + 0x00AB => Code::MediaTrackNext, + 0x00AC => Code::MediaPlayPause, + 0x00AD => Code::MediaTrackPrevious, + 0x00AE => Code::MediaStop, + 0x00B3 => Code::MediaSelect, + 0x00B4 => Code::BrowserHome, + 0x00B5 => Code::BrowserRefresh, + 0x00E1 => Code::BrowserSearch, + _ => Code::Unidentified, + } +} + +// Extracts the keyboard modifiers from, e.g., the `state` field of +// `x11rb::protocol::xproto::ButtonPressEvent` +pub(super) fn key_mods(mods: KeyButMask) -> Modifiers { + let mut ret = Modifiers::default(); + let key_masks = [ + (KeyButMask::SHIFT, Modifiers::SHIFT), + (KeyButMask::CONTROL, Modifiers::CONTROL), + // X11's mod keys are configurable, but this seems + // like a reasonable default for US keyboards, at least, + // where the "windows" key seems to be MOD_MASK_4. + (KeyButMask::MOD1, Modifiers::ALT), + (KeyButMask::MOD2, Modifiers::NUM_LOCK), + (KeyButMask::MOD4, Modifiers::META), + (KeyButMask::LOCK, Modifiers::CAPS_LOCK), + ]; + for (mask, modifiers) in &key_masks { + if mods.contains(*mask) { + ret |= *modifiers; + } + } + ret +} + +pub(super) fn convert_key_press_event(key_press: &KeyPressEvent) -> KeyboardEvent { + let hw_keycode = key_press.detail; + let code = hardware_keycode_to_code(hw_keycode.into()); + let modifiers = key_mods(key_press.state); + let key = code_to_key(code, modifiers); + let location = code_to_location(code); + let state = KeyState::Down; + + KeyboardEvent { code, key, modifiers, location, state, repeat: false, is_composing: false } +} + +pub(super) fn convert_key_release_event(key_release: &KeyReleaseEvent) -> KeyboardEvent { + let hw_keycode = key_release.detail; + let code = hardware_keycode_to_code(hw_keycode.into()); + let modifiers = key_mods(key_release.state); + let key = code_to_key(code, modifiers); + let location = code_to_location(code); + let state = KeyState::Up; + + KeyboardEvent { code, key, modifiers, location, state, repeat: false, is_composing: false } +} diff --git a/crates/baseview/src/x11/mod.rs b/crates/baseview/src/x11/mod.rs new file mode 100644 index 0000000..149df0b --- /dev/null +++ b/crates/baseview/src/x11/mod.rs @@ -0,0 +1,10 @@ +mod xcb_connection; +use xcb_connection::XcbConnection; + +mod window; +pub use window::*; + +mod cursor; +mod event_loop; +mod keyboard; +mod visual_info; diff --git a/crates/baseview/src/x11/visual_info.rs b/crates/baseview/src/x11/visual_info.rs new file mode 100644 index 0000000..3f1be38 --- /dev/null +++ b/crates/baseview/src/x11/visual_info.rs @@ -0,0 +1,94 @@ +use crate::x11::xcb_connection::XcbConnection; +use std::error::Error; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ + Colormap, ColormapAlloc, ConnectionExt, Screen, VisualClass, Visualid, +}; +use x11rb::COPY_FROM_PARENT; + +pub(super) struct WindowVisualConfig { + #[cfg(feature = "opengl")] + pub fb_config: Option, + + pub visual_depth: u8, + pub visual_id: Visualid, + pub color_map: Option, +} + +// TODO: make visual negotiation actually check all of a visual's parameters +impl WindowVisualConfig { + #[cfg(feature = "opengl")] + pub fn find_best_visual_config_for_gl( + connection: &XcbConnection, gl_config: Option, + ) -> Result> { + let Some(gl_config) = gl_config else { return Self::find_best_visual_config(connection) }; + + // SAFETY: TODO + let (fb_config, window_config) = unsafe { + crate::gl::platform::GlContext::get_fb_config_and_visual(connection.dpy, gl_config) + } + .expect("Could not fetch framebuffer config"); + + Ok(Self { + fb_config: Some(fb_config), + visual_depth: window_config.depth, + visual_id: window_config.visual, + color_map: Some(create_color_map(connection, window_config.visual)?), + }) + } + + pub fn find_best_visual_config(connection: &XcbConnection) -> Result> { + match find_visual_for_depth(connection.screen(), 32) { + None => Ok(Self::copy_from_parent()), + Some(visual_id) => Ok(Self { + #[cfg(feature = "opengl")] + fb_config: None, + visual_id, + visual_depth: 32, + color_map: Some(create_color_map(connection, visual_id)?), + }), + } + } + + const fn copy_from_parent() -> Self { + Self { + #[cfg(feature = "opengl")] + fb_config: None, + visual_depth: COPY_FROM_PARENT as u8, + visual_id: COPY_FROM_PARENT, + color_map: None, + } + } +} + +// For this 32-bit depth to work, you also need to define a color map and set a border +// pixel: https://cgit.freedesktop.org/xorg/xserver/tree/dix/window.c#n818 +fn create_color_map( + connection: &XcbConnection, visual_id: Visualid, +) -> Result> { + let colormap = connection.conn.generate_id()?; + connection.conn.create_colormap( + ColormapAlloc::NONE, + colormap, + connection.screen().root, + visual_id, + )?; + + Ok(colormap) +} + +fn find_visual_for_depth(screen: &Screen, depth: u8) -> Option { + for candidate_depth in &screen.allowed_depths { + if candidate_depth.depth != depth { + continue; + } + + for candidate_visual in &candidate_depth.visuals { + if candidate_visual.class == VisualClass::TRUE_COLOR { + return Some(candidate_visual.visual_id); + } + } + } + + None +} diff --git a/crates/baseview/src/x11/window.rs b/crates/baseview/src/x11/window.rs new file mode 100644 index 0000000..e556fe2 --- /dev/null +++ b/crates/baseview/src/x11/window.rs @@ -0,0 +1,376 @@ +use std::cell::Cell; +use std::error::Error; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +use raw_window_handle::{ + HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle, XlibDisplayHandle, + XlibWindowHandle, +}; + +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ + AtomEnum, ChangeWindowAttributesAux, ConfigureWindowAux, ConnectionExt as _, CreateGCAux, + CreateWindowAux, EventMask, PropMode, Visualid, Window as XWindow, WindowClass, +}; +use x11rb::wrapper::ConnectionExt as _; + +use super::XcbConnection; +use crate::{ + Event, MouseCursor, Size, WindowEvent, WindowHandler, WindowInfo, WindowOpenOptions, + WindowScalePolicy, +}; + +#[cfg(feature = "opengl")] +use crate::gl::{platform, GlContext}; +use crate::x11::event_loop::EventLoop; +use crate::x11::visual_info::WindowVisualConfig; + +pub struct WindowHandle { + raw_window_handle: Option, + event_loop_handle: Option>, + close_requested: Arc, + is_open: Arc, +} + +impl WindowHandle { + pub fn close(&mut self) { + self.close_requested.store(true, Ordering::Relaxed); + if let Some(event_loop) = self.event_loop_handle.take() { + let _ = event_loop.join(); + } + } + + pub fn is_open(&self) -> bool { + self.is_open.load(Ordering::Relaxed) + } +} + +unsafe impl HasRawWindowHandle for WindowHandle { + fn raw_window_handle(&self) -> RawWindowHandle { + if let Some(raw_window_handle) = self.raw_window_handle { + if self.is_open.load(Ordering::Relaxed) { + return raw_window_handle; + } + } + + RawWindowHandle::Xlib(XlibWindowHandle::empty()) + } +} + +pub(crate) struct ParentHandle { + close_requested: Arc, + is_open: Arc, +} + +impl ParentHandle { + pub fn new() -> (Self, WindowHandle) { + let close_requested = Arc::new(AtomicBool::new(false)); + let is_open = Arc::new(AtomicBool::new(true)); + let handle = WindowHandle { + raw_window_handle: None, + event_loop_handle: None, + close_requested: Arc::clone(&close_requested), + is_open: Arc::clone(&is_open), + }; + + (Self { close_requested, is_open }, handle) + } + + pub fn parent_did_drop(&self) -> bool { + self.close_requested.load(Ordering::Relaxed) + } +} + +impl Drop for ParentHandle { + fn drop(&mut self) { + self.is_open.store(false, Ordering::Relaxed); + } +} + +pub(crate) struct WindowInner { + // GlContext should be dropped **before** XcbConnection is dropped + #[cfg(feature = "opengl")] + gl_context: Option, + + pub(crate) xcb_connection: XcbConnection, + window_id: XWindow, + pub(crate) window_info: WindowInfo, + visual_id: Visualid, + mouse_cursor: Cell, + + pub(crate) close_requested: Cell, +} + +pub struct Window<'a> { + pub(crate) inner: &'a WindowInner, +} + +// Hack to allow sending a RawWindowHandle between threads. Do not make public +struct SendableRwh(RawWindowHandle); + +unsafe impl Send for SendableRwh {} + +type WindowOpenResult = Result; + +impl<'a> Window<'a> { + pub fn open_parented(parent: &P, options: WindowOpenOptions, build: B) -> WindowHandle + where + P: HasRawWindowHandle, + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + // Convert parent into something that X understands + let parent_id = match parent.raw_window_handle() { + RawWindowHandle::Xlib(h) => h.window as u32, + RawWindowHandle::Xcb(h) => h.window, + h => panic!("unsupported parent handle type {:?}", h), + }; + + let (tx, rx) = mpsc::sync_channel::(1); + let (parent_handle, mut window_handle) = ParentHandle::new(); + let join_handle = thread::spawn(move || { + Self::window_thread(Some(parent_id), options, build, tx.clone(), Some(parent_handle)) + .unwrap(); + }); + + let raw_window_handle = rx.recv().unwrap().unwrap(); + window_handle.raw_window_handle = Some(raw_window_handle.0); + window_handle.event_loop_handle = Some(join_handle); + window_handle + } + + pub fn open_blocking(options: WindowOpenOptions, build: B) + where + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + let (tx, rx) = mpsc::sync_channel::(1); + + let thread = thread::spawn(move || { + Self::window_thread(None, options, build, tx, None).unwrap(); + }); + + let _ = rx.recv().unwrap().unwrap(); + + thread.join().unwrap_or_else(|err| { + eprintln!("Window thread panicked: {:#?}", err); + }); + } + + fn window_thread( + parent: Option, options: WindowOpenOptions, build: B, + tx: mpsc::SyncSender, parent_handle: Option, + ) -> Result<(), Box> + where + H: WindowHandler + 'static, + B: FnOnce(&mut crate::Window) -> H, + B: Send + 'static, + { + // Connect to the X server + // FIXME: baseview error type instead of unwrap() + let xcb_connection = XcbConnection::new()?; + + // Get screen information + let screen = xcb_connection.screen(); + let parent_id = parent.unwrap_or(screen.root); + + let gc_id = xcb_connection.conn.generate_id()?; + xcb_connection.conn.create_gc( + gc_id, + parent_id, + &CreateGCAux::new().foreground(screen.black_pixel).graphics_exposures(0), + )?; + + let scaling = match options.scale { + WindowScalePolicy::SystemScaleFactor => xcb_connection.get_scaling().unwrap_or(1.0), + WindowScalePolicy::ScaleFactor(scale) => scale, + }; + + let window_info = WindowInfo::from_logical_size(options.size, scaling); + + #[cfg(feature = "opengl")] + let visual_info = + WindowVisualConfig::find_best_visual_config_for_gl(&xcb_connection, options.gl_config)?; + + #[cfg(not(feature = "opengl"))] + let visual_info = WindowVisualConfig::find_best_visual_config(&xcb_connection)?; + + let window_id = xcb_connection.conn.generate_id()?; + xcb_connection.conn.create_window( + visual_info.visual_depth, + window_id, + parent_id, + 0, // x coordinate of the new window + 0, // y coordinate of the new window + window_info.physical_size().width as u16, // window width + window_info.physical_size().height as u16, // window height + 0, // window border + WindowClass::INPUT_OUTPUT, + visual_info.visual_id, + &CreateWindowAux::new() + .event_mask( + EventMask::EXPOSURE + | EventMask::POINTER_MOTION + | EventMask::BUTTON_PRESS + | EventMask::BUTTON_RELEASE + | EventMask::KEY_PRESS + | EventMask::KEY_RELEASE + | EventMask::STRUCTURE_NOTIFY + | EventMask::ENTER_WINDOW + | EventMask::LEAVE_WINDOW, + ) + // As mentioned above, these two values are needed to be able to create a window + // with a depth of 32-bits when the parent window has a different depth + .colormap(visual_info.color_map) + .border_pixel(0), + )?; + xcb_connection.conn.map_window(window_id)?; + + // Change window title + let title = options.title; + xcb_connection.conn.change_property8( + PropMode::REPLACE, + window_id, + AtomEnum::WM_NAME, + AtomEnum::STRING, + title.as_bytes(), + )?; + + xcb_connection.conn.change_property32( + PropMode::REPLACE, + window_id, + xcb_connection.atoms.WM_PROTOCOLS, + AtomEnum::ATOM, + &[xcb_connection.atoms.WM_DELETE_WINDOW], + )?; + + xcb_connection.conn.flush()?; + + // TODO: These APIs could use a couple tweaks now that everything is internal and there is + // no error handling anymore at this point. Everything is more or less unchanged + // compared to when raw-gl-context was a separate crate. + #[cfg(feature = "opengl")] + let gl_context = visual_info.fb_config.map(|fb_config| { + use std::ffi::c_ulong; + + let window = window_id as c_ulong; + let display = xcb_connection.dpy; + + // Because of the visual negotation we had to take some extra steps to create this context + let context = unsafe { platform::GlContext::create(window, display, fb_config) } + .expect("Could not create OpenGL context"); + GlContext::new(context) + }); + + let mut inner = WindowInner { + xcb_connection, + window_id, + window_info, + visual_id: visual_info.visual_id, + mouse_cursor: Cell::new(MouseCursor::default()), + + close_requested: Cell::new(false), + + #[cfg(feature = "opengl")] + gl_context, + }; + + let mut window = crate::Window::new(Window { inner: &mut inner }); + + let mut handler = build(&mut window); + + // Send an initial window resized event so the user is alerted of + // the correct dpi scaling. + handler.on_event(&mut window, Event::Window(WindowEvent::Resized(window_info))); + + let _ = tx.send(Ok(SendableRwh(window.raw_window_handle()))); + + EventLoop::new(inner, handler, parent_handle).run()?; + + Ok(()) + } + + pub fn set_mouse_cursor(&self, mouse_cursor: MouseCursor) { + if self.inner.mouse_cursor.get() == mouse_cursor { + return; + } + + let xid = self.inner.xcb_connection.get_cursor(mouse_cursor).unwrap(); + + if xid != 0 { + let _ = self.inner.xcb_connection.conn.change_window_attributes( + self.inner.window_id, + &ChangeWindowAttributesAux::new().cursor(xid), + ); + let _ = self.inner.xcb_connection.conn.flush(); + } + + self.inner.mouse_cursor.set(mouse_cursor); + } + + pub fn close(&mut self) { + self.inner.close_requested.set(true); + } + + pub fn has_focus(&mut self) -> bool { + unimplemented!() + } + + pub fn focus(&mut self) { + unimplemented!() + } + + pub fn resize(&mut self, size: Size) { + let scaling = self.inner.window_info.scale(); + let new_window_info = WindowInfo::from_logical_size(size, scaling); + + let _ = self.inner.xcb_connection.conn.configure_window( + self.inner.window_id, + &ConfigureWindowAux::new() + .width(new_window_info.physical_size().width) + .height(new_window_info.physical_size().height), + ); + let _ = self.inner.xcb_connection.conn.flush(); + + // This will trigger a `ConfigureNotify` event which will in turn change `self.window_info` + // and notify the window handler about it + } + + #[cfg(feature = "opengl")] + pub fn gl_context(&self) -> Option<&crate::gl::GlContext> { + self.inner.gl_context.as_ref() + } +} + +unsafe impl<'a> HasRawWindowHandle for Window<'a> { + fn raw_window_handle(&self) -> RawWindowHandle { + let mut handle = XlibWindowHandle::empty(); + + handle.window = self.inner.window_id.into(); + handle.visual_id = self.inner.visual_id.into(); + + RawWindowHandle::Xlib(handle) + } +} + +unsafe impl<'a> HasRawDisplayHandle for Window<'a> { + fn raw_display_handle(&self) -> RawDisplayHandle { + let display = self.inner.xcb_connection.dpy; + let mut handle = XlibDisplayHandle::empty(); + + handle.display = display as *mut c_void; + handle.screen = unsafe { x11::xlib::XDefaultScreen(display) }; + + RawDisplayHandle::Xlib(handle) + } +} + +pub fn copy_to_clipboard(_data: &str) { + todo!() +} diff --git a/crates/baseview/src/x11/xcb_connection.rs b/crates/baseview/src/x11/xcb_connection.rs new file mode 100644 index 0000000..a5ea06d --- /dev/null +++ b/crates/baseview/src/x11/xcb_connection.rs @@ -0,0 +1,132 @@ +use std::cell::RefCell; +use std::collections::hash_map::{Entry, HashMap}; +use std::error::Error; + +use x11::{xlib, xlib::Display, xlib_xcb}; + +use x11rb::connection::Connection; +use x11rb::cursor::Handle as CursorHandle; +use x11rb::protocol::xproto::{Cursor, Screen}; +use x11rb::resource_manager; +use x11rb::xcb_ffi::XCBConnection; + +use crate::MouseCursor; + +use super::cursor; + +x11rb::atom_manager! { + pub Atoms: AtomsCookie { + WM_PROTOCOLS, + WM_DELETE_WINDOW, + } +} + +/// A very light abstraction around the XCB connection. +/// +/// Keeps track of the xcb connection itself and the xlib display ID that was used to connect. +pub struct XcbConnection { + pub(crate) dpy: *mut Display, + pub(crate) conn: XCBConnection, + pub(crate) screen: usize, + pub(crate) atoms: Atoms, + pub(crate) resources: resource_manager::Database, + pub(crate) cursor_handle: CursorHandle, + pub(super) cursor_cache: RefCell>, +} + +impl XcbConnection { + pub fn new() -> Result> { + let dpy = unsafe { xlib::XOpenDisplay(std::ptr::null()) }; + assert!(!dpy.is_null()); + let xcb_connection = unsafe { xlib_xcb::XGetXCBConnection(dpy) }; + assert!(!xcb_connection.is_null()); + let screen = unsafe { xlib::XDefaultScreen(dpy) } as usize; + let conn = unsafe { XCBConnection::from_raw_xcb_connection(xcb_connection, false)? }; + unsafe { + xlib_xcb::XSetEventQueueOwner(dpy, xlib_xcb::XEventQueueOwner::XCBOwnsEventQueue) + }; + + let atoms = Atoms::new(&conn)?.reply()?; + let resources = resource_manager::new_from_default(&conn)?; + let cursor_handle = CursorHandle::new(&conn, screen, &resources)?.reply()?; + + Ok(Self { + dpy, + conn, + screen, + atoms, + resources, + cursor_handle, + cursor_cache: RefCell::new(HashMap::new()), + }) + } + + // Try to get the scaling with this function first. + // If this gives you `None`, fall back to `get_scaling_screen_dimensions`. + // If neither work, I guess just assume 96.0 and don't do any scaling. + fn get_scaling_xft(&self) -> Result, Box> { + if let Some(dpi) = self.resources.get_value::("Xft.dpi", "")? { + Ok(Some(dpi as f64 / 96.0)) + } else { + Ok(None) + } + } + + // Try to get the scaling with `get_scaling_xft` first. + // Only use this function as a fallback. + // If neither work, I guess just assume 96.0 and don't do any scaling. + fn get_scaling_screen_dimensions(&self) -> f64 { + // Figure out screen information + let screen = self.screen(); + + // Get the DPI from the screen struct + // + // there are 2.54 centimeters to an inch; so there are 25.4 millimeters. + // dpi = N pixels / (M millimeters / (25.4 millimeters / 1 inch)) + // = N pixels / (M inch / 25.4) + // = N * 25.4 pixels / M inch + let width_px = screen.width_in_pixels as f64; + let width_mm = screen.width_in_millimeters as f64; + let height_px = screen.height_in_pixels as f64; + let height_mm = screen.height_in_millimeters as f64; + let _xres = width_px * 25.4 / width_mm; + let yres = height_px * 25.4 / height_mm; + + // TODO: choose between `xres` and `yres`? (probably both are the same?) + yres / 96.0 + } + + #[inline] + pub fn get_scaling(&self) -> Result> { + Ok(self.get_scaling_xft()?.unwrap_or(self.get_scaling_screen_dimensions())) + } + + #[inline] + pub fn get_cursor(&self, cursor: MouseCursor) -> Result> { + // PANIC: this function is the only point where we access the cache, and we never call + // external functions that may make a reentrant call to this function + let mut cursor_cache = self.cursor_cache.borrow_mut(); + + match cursor_cache.entry(cursor) { + Entry::Occupied(entry) => Ok(*entry.get()), + Entry::Vacant(entry) => { + let cursor = + cursor::get_xcursor(&self.conn, self.screen, &self.cursor_handle, cursor)?; + entry.insert(cursor); + Ok(cursor) + } + } + } + + pub fn screen(&self) -> &Screen { + &self.conn.setup().roots[self.screen] + } +} + +impl Drop for XcbConnection { + fn drop(&mut self) { + unsafe { + xlib::XCloseDisplay(self.dpy); + } + } +} diff --git a/crates/clap/Cargo.toml b/crates/clap/Cargo.toml new file mode 100644 index 0000000..2e88d74 --- /dev/null +++ b/crates/clap/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "cagire-clap" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Cagire as a CLAP audio plugin" + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +cagire = { path = "../.." } +cagire-forth = { path = "../forth" } +cagire-project = { path = "../project" } +cagire-ratatui = { path = "../ratatui" } +doux = { git = "https://github.com/Bubobubobubobubo/doux", features = ["native"] } +nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] } +nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" } +egui_ratatui = "2.1" +soft_ratatui = { version = "0.1.3", features = ["unicodefonts"] } +ratatui = "0.30" +crossterm = "0.29" +crossbeam-channel = "0.5" +arc-swap = "1" +parking_lot = "0.12" +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ringbuf = "0.4" diff --git a/crates/clap/src/editor.rs b/crates/clap/src/editor.rs new file mode 100644 index 0000000..75d690d --- /dev/null +++ b/crates/clap/src/editor.rs @@ -0,0 +1,274 @@ +use std::sync::atomic::{AtomicBool, AtomicI64}; +use std::sync::Arc; +use std::time::Instant; + +use arc_swap::ArcSwap; +use crossbeam_channel::Sender; +use egui_ratatui::RataguiBackend; +use nih_plug::prelude::*; +use nih_plug_egui::egui; +use nih_plug_egui::{create_egui_editor, EguiState}; +use ratatui::Terminal; +use soft_ratatui::embedded_graphics_unicodefonts::{ + mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas, + mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas, + mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas, + mono_9x18_atlas, mono_9x18_bold_atlas, +}; +use soft_ratatui::{EmbeddedGraphics, SoftBackend}; + +use cagire::app::App; +use cagire::engine::{AudioCommand, LinkState, SequencerSnapshot}; +use cagire::input::{handle_key, handle_mouse, InputContext}; +use cagire::model::{Dictionary, Rng, Variables}; +use cagire::theme; +use cagire::views; + +use crate::input_egui::{convert_egui_events, convert_egui_mouse}; +use crate::params::CagireParams; +use crate::PluginBridge; + +type TerminalType = Terminal>; + +#[derive(Clone, Copy, PartialEq)] +enum FontChoice { + Size6x13, + Size7x13, + Size8x13, + Size9x15, + Size9x18, + Size10x20, +} + +impl FontChoice { + fn label(self) -> &'static str { + match self { + Self::Size6x13 => "6x13 (Compact)", + Self::Size7x13 => "7x13", + Self::Size8x13 => "8x13 (Default)", + Self::Size9x15 => "9x15", + Self::Size9x18 => "9x18", + Self::Size10x20 => "10x20 (Large)", + } + } + + const ALL: [Self; 6] = [ + Self::Size6x13, + Self::Size7x13, + Self::Size8x13, + Self::Size9x15, + Self::Size9x18, + Self::Size10x20, + ]; +} + +fn create_terminal(font: FontChoice) -> TerminalType { + let (regular, bold, italic) = match font { + FontChoice::Size6x13 => ( + mono_6x13_atlas(), + Some(mono_6x13_bold_atlas()), + Some(mono_6x13_italic_atlas()), + ), + FontChoice::Size7x13 => ( + mono_7x13_atlas(), + Some(mono_7x13_bold_atlas()), + Some(mono_7x13_italic_atlas()), + ), + FontChoice::Size8x13 => ( + mono_8x13_atlas(), + Some(mono_8x13_bold_atlas()), + Some(mono_8x13_italic_atlas()), + ), + FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None), + FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None), + FontChoice::Size10x20 => (mono_10x20_atlas(), None, None), + }; + + let soft = SoftBackend::::new(80, 24, regular, bold, italic); + Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") +} + +struct EditorState { + app: App, + terminal: TerminalType, + link: Arc, + snapshot: SequencerSnapshot, + playing: Arc, + nudge_us: Arc, + audio_tx: Arc>>, + bridge: Arc, + params: Arc, + last_frame: Instant, + current_font: FontChoice, + zoom_factor: f32, +} + +// SAFETY: EditorState is only accessed from the GUI thread via RwLock. +// The non-Send types (RefCell in App's tachyonfx effects) are never shared +// across threads — baseview's event loop guarantees single-threaded access. +unsafe impl Send for EditorState {} +unsafe impl Sync for EditorState {} + +pub fn create_editor( + params: Arc, + egui_state: Arc, + bridge: Arc, + variables: Variables, + dict: Dictionary, + rng: Rng, +) -> Option> { + create_egui_editor( + egui_state, + None::, + |ctx, _state| { + ctx.set_visuals(egui::Visuals { + panel_fill: egui::Color32::BLACK, + ..egui::Visuals::dark() + }); + }, + move |ctx, _setter, state| { + let editor = state.get_or_insert_with(|| { + let palette = + cagire::state::ColorScheme::default().to_palette(); + theme::set(cagire_ratatui::theme::build::build(&palette)); + + let mut app = App::with_shared( + Arc::clone(&variables), + Arc::clone(&dict), + Arc::clone(&rng), + ); + app.plugin_mode = true; + app.audio.section = cagire::state::EngineSection::Settings; + app.audio.setting_kind = cagire::state::SettingKind::Polyphony; + + // Load persisted project + app.project_state.project = params.project.lock().clone(); + app.mark_all_patterns_dirty(); + + EditorState { + app, + terminal: create_terminal(FontChoice::Size8x13), + link: Arc::new(LinkState::new( + params.tempo.value() as f64, + 4.0, + )), + snapshot: SequencerSnapshot::empty(), + playing: Arc::new(AtomicBool::new(false)), + nudge_us: Arc::new(AtomicI64::new(0)), + audio_tx: Arc::new(ArcSwap::from_pointee(bridge.audio_cmd_tx.clone())), + bridge: Arc::clone(&bridge), + params: Arc::clone(¶ms), + last_frame: Instant::now(), + current_font: FontChoice::Size8x13, + zoom_factor: 1.0, + } + }); + + // Flush pattern data and queued changes to the audio thread + let had_dirty = editor.app.flush_dirty_patterns(&editor.bridge.cmd_tx); + editor.app.flush_queued_changes(&editor.bridge.cmd_tx); + + // Sync project changes back to persisted params + if had_dirty { + *editor.params.project.lock() = editor.app.project_state.project.clone(); + } + + // Sync sample registry from the audio engine (set once after initialize) + if editor.app.audio.sample_registry.is_none() { + if let Some(reg) = editor.bridge.sample_registry.load().as_ref() { + editor.app.audio.sample_registry = Some(Arc::clone(reg)); + } + } + + // Read live snapshot from the audio thread + let shared = editor.bridge.shared_state.load(); + editor.snapshot = SequencerSnapshot::from(shared.as_ref()); + + // Feed scope and spectrum data into app metrics + editor.app.metrics.scope = editor.bridge.scope_buffer.read(); + (editor.app.metrics.peak_left, editor.app.metrics.peak_right) = + editor.bridge.scope_buffer.peaks(); + editor.app.metrics.spectrum = editor.bridge.spectrum_buffer.read(); + + // Handle input + let term = editor.terminal.get_frame().area(); + let widget_rect = ctx.content_rect(); + + for mouse in convert_egui_mouse(ctx, widget_rect, term) { + let mut input_ctx = InputContext { + app: &mut editor.app, + link: &editor.link, + snapshot: &editor.snapshot, + playing: &editor.playing, + audio_tx: &editor.audio_tx, + seq_cmd_tx: &editor.bridge.cmd_tx, + nudge_us: &editor.nudge_us, + }; + handle_mouse(&mut input_ctx, mouse, term); + } + + for key in convert_egui_events(ctx) { + let mut input_ctx = InputContext { + app: &mut editor.app, + link: &editor.link, + snapshot: &editor.snapshot, + playing: &editor.playing, + audio_tx: &editor.audio_tx, + seq_cmd_tx: &editor.bridge.cmd_tx, + nudge_us: &editor.nudge_us, + }; + let _ = handle_key(&mut input_ctx, key); + } + + cagire::state::effects::tick_effects(&mut editor.app.ui, editor.app.page); + + let elapsed = editor.last_frame.elapsed(); + editor.last_frame = Instant::now(); + + let link = &editor.link; + let app = &editor.app; + let snapshot = &editor.snapshot; + editor + .terminal + .draw(|frame| views::render(frame, app, link, snapshot, elapsed)) + .expect("draw"); + + let current_font = editor.current_font; + let current_zoom = editor.zoom_factor; + + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ctx, |ui| { + ui.add(editor.terminal.backend_mut()); + + let response = ui.interact( + ui.max_rect(), + egui::Id::new("terminal_context"), + egui::Sense::click(), + ); + response.context_menu(|ui| { + ui.menu_button("Font", |ui| { + for choice in FontChoice::ALL { + if ui.selectable_label(current_font == choice, choice.label()).clicked() { + editor.terminal = create_terminal(choice); + editor.current_font = choice; + ui.close(); + } + } + }); + ui.menu_button("Zoom", |ui| { + for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] { + let selected = (current_zoom - level).abs() < 0.01; + let label = format!("{:.0}%", level * 100.0); + if ui.selectable_label(selected, label).clicked() { + editor.zoom_factor = level; + ctx.set_zoom_factor(level); + ui.close(); + } + } + }); + }); + }); + }, + ) +} diff --git a/crates/clap/src/input_egui.rs b/crates/clap/src/input_egui.rs new file mode 100644 index 0000000..525acce --- /dev/null +++ b/crates/clap/src/input_egui.rs @@ -0,0 +1,258 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use nih_plug_egui::egui; +use ratatui::layout::Rect; + +pub fn convert_egui_mouse( + ctx: &egui::Context, + widget_rect: egui::Rect, + term: Rect, +) -> Vec { + let mut events = Vec::new(); + if widget_rect.width() < 1.0 + || widget_rect.height() < 1.0 + || term.width == 0 + || term.height == 0 + { + return events; + } + + ctx.input(|i| { + let Some(pos) = i.pointer.latest_pos() else { + return; + }; + if !widget_rect.contains(pos) { + return; + } + + let col = + ((pos.x - widget_rect.left()) / widget_rect.width() * term.width as f32) as u16; + let row = + ((pos.y - widget_rect.top()) / widget_rect.height() * term.height as f32) as u16; + let col = col.min(term.width.saturating_sub(1)); + let row = row.min(term.height.saturating_sub(1)); + + if i.pointer.button_clicked(egui::PointerButton::Primary) { + events.push(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: col, + row, + modifiers: KeyModifiers::empty(), + }); + } + + let scroll = i.raw_scroll_delta.y; + if scroll > 1.0 { + events.push(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: col, + row, + modifiers: KeyModifiers::empty(), + }); + } else if scroll < -1.0 { + events.push(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: col, + row, + modifiers: KeyModifiers::empty(), + }); + } + }); + + events +} + +pub fn convert_egui_events(ctx: &egui::Context) -> Vec { + let mut events = Vec::new(); + + for event in &ctx.input(|i| i.events.clone()) { + if let Some(key_event) = convert_event(event) { + events.push(key_event); + } + } + + events +} + +fn convert_event(event: &egui::Event) -> Option { + match event { + egui::Event::Key { + key, + pressed, + modifiers, + .. + } => { + if !*pressed { + return None; + } + let mods = convert_modifiers(*modifiers); + if is_character_key(*key) + && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return None; + } + let code = convert_key(*key)?; + Some(KeyEvent::new(code, mods)) + } + egui::Event::Text(text) => { + if text.len() == 1 { + let c = text.chars().next()?; + if !c.is_control() { + return Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())); + } + } + None + } + egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)), + _ => None, + } +} + +fn convert_key(key: egui::Key) -> Option { + Some(match key { + egui::Key::ArrowDown => KeyCode::Down, + egui::Key::ArrowLeft => KeyCode::Left, + egui::Key::ArrowRight => KeyCode::Right, + egui::Key::ArrowUp => KeyCode::Up, + egui::Key::Escape => KeyCode::Esc, + egui::Key::Tab => KeyCode::Tab, + egui::Key::Backspace => KeyCode::Backspace, + egui::Key::Enter => KeyCode::Enter, + egui::Key::Space => KeyCode::Char(' '), + egui::Key::Insert => KeyCode::Insert, + egui::Key::Delete => KeyCode::Delete, + egui::Key::Home => KeyCode::Home, + egui::Key::End => KeyCode::End, + egui::Key::PageUp => KeyCode::PageUp, + egui::Key::PageDown => KeyCode::PageDown, + egui::Key::F1 => KeyCode::F(1), + egui::Key::F2 => KeyCode::F(2), + egui::Key::F3 => KeyCode::F(3), + egui::Key::F4 => KeyCode::F(4), + egui::Key::F5 => KeyCode::F(5), + egui::Key::F6 => KeyCode::F(6), + egui::Key::F7 => KeyCode::F(7), + egui::Key::F8 => KeyCode::F(8), + egui::Key::F9 => KeyCode::F(9), + egui::Key::F10 => KeyCode::F(10), + egui::Key::F11 => KeyCode::F(11), + egui::Key::F12 => KeyCode::F(12), + egui::Key::A => KeyCode::Char('a'), + egui::Key::B => KeyCode::Char('b'), + egui::Key::C => KeyCode::Char('c'), + egui::Key::D => KeyCode::Char('d'), + egui::Key::E => KeyCode::Char('e'), + egui::Key::F => KeyCode::Char('f'), + egui::Key::G => KeyCode::Char('g'), + egui::Key::H => KeyCode::Char('h'), + egui::Key::I => KeyCode::Char('i'), + egui::Key::J => KeyCode::Char('j'), + egui::Key::K => KeyCode::Char('k'), + egui::Key::L => KeyCode::Char('l'), + egui::Key::M => KeyCode::Char('m'), + egui::Key::N => KeyCode::Char('n'), + egui::Key::O => KeyCode::Char('o'), + egui::Key::P => KeyCode::Char('p'), + egui::Key::Q => KeyCode::Char('q'), + egui::Key::R => KeyCode::Char('r'), + egui::Key::S => KeyCode::Char('s'), + egui::Key::T => KeyCode::Char('t'), + egui::Key::U => KeyCode::Char('u'), + egui::Key::V => KeyCode::Char('v'), + egui::Key::W => KeyCode::Char('w'), + egui::Key::X => KeyCode::Char('x'), + egui::Key::Y => KeyCode::Char('y'), + egui::Key::Z => KeyCode::Char('z'), + egui::Key::Num0 => KeyCode::Char('0'), + egui::Key::Num1 => KeyCode::Char('1'), + egui::Key::Num2 => KeyCode::Char('2'), + egui::Key::Num3 => KeyCode::Char('3'), + egui::Key::Num4 => KeyCode::Char('4'), + egui::Key::Num5 => KeyCode::Char('5'), + egui::Key::Num6 => KeyCode::Char('6'), + egui::Key::Num7 => KeyCode::Char('7'), + egui::Key::Num8 => KeyCode::Char('8'), + egui::Key::Num9 => KeyCode::Char('9'), + egui::Key::Minus => KeyCode::Char('-'), + egui::Key::Equals => KeyCode::Char('='), + egui::Key::OpenBracket => KeyCode::Char('['), + egui::Key::CloseBracket => KeyCode::Char(']'), + egui::Key::Semicolon => KeyCode::Char(';'), + egui::Key::Comma => KeyCode::Char(','), + egui::Key::Period => KeyCode::Char('.'), + egui::Key::Slash => KeyCode::Char('/'), + egui::Key::Backslash => KeyCode::Char('\\'), + egui::Key::Backtick => KeyCode::Char('`'), + egui::Key::Quote => KeyCode::Char('\''), + _ => return None, + }) +} + +fn convert_modifiers(mods: egui::Modifiers) -> KeyModifiers { + let mut result = KeyModifiers::empty(); + if mods.shift { + result |= KeyModifiers::SHIFT; + } + if mods.ctrl || mods.command { + result |= KeyModifiers::CONTROL; + } + if mods.alt { + result |= KeyModifiers::ALT; + } + result +} + +fn is_character_key(key: egui::Key) -> bool { + matches!( + key, + egui::Key::A + | egui::Key::B + | egui::Key::C + | egui::Key::D + | egui::Key::E + | egui::Key::F + | egui::Key::G + | egui::Key::H + | egui::Key::I + | egui::Key::J + | egui::Key::K + | egui::Key::L + | egui::Key::M + | egui::Key::N + | egui::Key::O + | egui::Key::P + | egui::Key::Q + | egui::Key::R + | egui::Key::S + | egui::Key::T + | egui::Key::U + | egui::Key::V + | egui::Key::W + | egui::Key::X + | egui::Key::Y + | egui::Key::Z + | egui::Key::Num0 + | egui::Key::Num1 + | egui::Key::Num2 + | egui::Key::Num3 + | egui::Key::Num4 + | egui::Key::Num5 + | egui::Key::Num6 + | egui::Key::Num7 + | egui::Key::Num8 + | egui::Key::Num9 + | egui::Key::Space + | egui::Key::Minus + | egui::Key::Equals + | egui::Key::OpenBracket + | egui::Key::CloseBracket + | egui::Key::Semicolon + | egui::Key::Comma + | egui::Key::Period + | egui::Key::Slash + | egui::Key::Backslash + | egui::Key::Backtick + | egui::Key::Quote + ) +} diff --git a/crates/clap/src/lib.rs b/crates/clap/src/lib.rs new file mode 100644 index 0000000..b6a1532 --- /dev/null +++ b/crates/clap/src/lib.rs @@ -0,0 +1,460 @@ +mod editor; +mod input_egui; +mod params; + +use std::collections::HashMap; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use crossbeam_channel::{bounded, Receiver, Sender}; +use nih_plug::prelude::*; +use parking_lot::Mutex; +use rand::rngs::StdRng; +use rand::SeedableRng; +use ringbuf::traits::Producer; + +use cagire::engine::{ + parse_midi_command, spawn_analysis_thread, AnalysisHandle, AudioCommand, MidiCommand, + PatternSnapshot, ScopeBuffer, SeqCommand, SequencerState, SharedSequencerState, SpectrumBuffer, + StepSnapshot, TickInput, +}; +use cagire::model::{Dictionary, Rng, Variables}; +use params::CagireParams; + +pub struct PluginBridge { + pub cmd_tx: Sender, + pub cmd_rx: Receiver, + pub audio_cmd_tx: Sender, + pub audio_cmd_rx: Receiver, + pub shared_state: Arc>, + pub scope_buffer: Arc, + pub spectrum_buffer: Arc, + pub sample_registry: ArcSwap>>, +} + +struct PendingNoteOff { + target_sample: u64, + channel: u8, + note: u8, +} + +pub struct CagirePlugin { + params: Arc, + seq_state: Option, + engine: Option, + sample_rate: f32, + prev_beat: f64, + sample_pos: u64, + bridge: Arc, + variables: Variables, + dict: Dictionary, + rng: Rng, + cmd_buffer: String, + audio_buffer: Vec, + fft_producer: Option>, + _analysis: Option, + pending_note_offs: Vec, +} + +impl Default for CagirePlugin { + fn default() -> Self { + let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new())); + let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); + let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); + + let (cmd_tx, cmd_rx) = bounded(64); + let (audio_cmd_tx, audio_cmd_rx) = bounded(64); + let bridge = Arc::new(PluginBridge { + cmd_tx, + cmd_rx, + audio_cmd_tx, + audio_cmd_rx, + shared_state: Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())), + scope_buffer: Arc::new(ScopeBuffer::default()), + spectrum_buffer: Arc::new(SpectrumBuffer::default()), + sample_registry: ArcSwap::from_pointee(None), + }); + + Self { + params: Arc::new(CagireParams::default()), + seq_state: None, + engine: None, + sample_rate: 44100.0, + prev_beat: -1.0, + sample_pos: 0, + bridge, + variables, + dict, + rng, + cmd_buffer: String::with_capacity(256), + audio_buffer: Vec::new(), + fft_producer: None, + _analysis: None, + pending_note_offs: Vec::new(), + } + } +} + +impl Plugin for CagirePlugin { + type SysExMessage = (); + type BackgroundTask = (); + + const NAME: &'static str = "Cagire"; + const VENDOR: &'static str = "Bubobubobubobubo"; + const URL: &'static str = "https://cagire.raphaelforment.fr"; + const EMAIL: &'static str = "raphael.forment@gmail.com"; + const VERSION: &'static str = env!("CARGO_PKG_VERSION"); + + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout { + main_input_channels: None, + main_output_channels: Some(new_nonzero_u32(2)), + aux_input_ports: &[], + aux_output_ports: &[], + names: PortNames { + layout: Some("Stereo"), + main_input: None, + main_output: Some("Output"), + aux_inputs: &[], + aux_outputs: &[], + }, + }]; + + const MIDI_OUTPUT: MidiConfig = MidiConfig::MidiCCs; + + fn params(&self) -> Arc { + self.params.clone() + } + + fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { + editor::create_editor( + self.params.clone(), + self.params.editor_state.clone(), + Arc::clone(&self.bridge), + Arc::clone(&self.variables), + Arc::clone(&self.dict), + Arc::clone(&self.rng), + ) + } + + fn initialize( + &mut self, + _audio_io_layout: &AudioIOLayout, + buffer_config: &BufferConfig, + _context: &mut impl InitContext, + ) -> bool { + self.sample_rate = buffer_config.sample_rate; + self.sample_pos = 0; + self.prev_beat = -1.0; + + self.seq_state = Some(SequencerState::new( + Arc::clone(&self.variables), + Arc::clone(&self.dict), + Arc::clone(&self.rng), + None, + )); + + let engine = doux::Engine::new_with_channels( + self.sample_rate, + 2, + 64, + ); + self.bridge + .sample_registry + .store(Arc::new(Some(Arc::clone(&engine.sample_registry)))); + self.engine = Some(engine); + + let (fft_producer, analysis_handle) = spawn_analysis_thread( + self.sample_rate, + Arc::clone(&self.bridge.spectrum_buffer), + ); + self.fft_producer = Some(fft_producer); + self._analysis = Some(analysis_handle); + + // Seed sequencer with persisted project data + let project = self.params.project.lock().clone(); + for (bank_idx, bank) in project.banks.iter().enumerate() { + for (pat_idx, pat) in bank.patterns.iter().enumerate() { + let has_content = pat.steps.iter().any(|s| !s.script.is_empty()); + if !has_content { + continue; + } + let snapshot = PatternSnapshot { + speed: pat.speed, + length: pat.length, + steps: pat + .steps + .iter() + .take(pat.length) + .map(|s| StepSnapshot { + active: s.active, + script: s.script.clone(), + source: s.source, + }) + .collect(), + quantization: pat.quantization, + sync_mode: pat.sync_mode, + }; + let _ = self.bridge.cmd_tx.send(SeqCommand::PatternUpdate { + bank: bank_idx, + pattern: pat_idx, + data: snapshot, + }); + } + } + + true + } + + fn reset(&mut self) { + self.prev_beat = -1.0; + self.sample_pos = 0; + if let Some(engine) = &mut self.engine { + engine.hush(); + } + } + + fn process( + &mut self, + buffer: &mut Buffer, + _aux: &mut AuxiliaryBuffers, + context: &mut impl ProcessContext, + ) -> ProcessStatus { + let Some(seq_state) = &mut self.seq_state else { + return ProcessStatus::Normal; + }; + let Some(engine) = &mut self.engine else { + return ProcessStatus::Normal; + }; + + let transport = context.transport(); + let buffer_len = buffer.samples(); + + let playing = transport.playing; + let tempo = transport.tempo.unwrap_or(self.params.tempo.value() as f64); + let beat = transport.pos_beats().unwrap_or(0.0); + let quantum = transport + .time_sig_numerator + .map(|n| n as f64) + .unwrap_or(4.0); + + let effective_tempo = if self.params.sync_to_host.value() { + tempo + } else { + self.params.tempo.value() as f64 + }; + + let buffer_secs = buffer_len as f64 / self.sample_rate as f64; + let lookahead_beats = if effective_tempo > 0.0 { + buffer_secs * effective_tempo / 60.0 + } else { + 0.0 + }; + let lookahead_end = beat + lookahead_beats; + + let engine_time = self.sample_pos as f64 / self.sample_rate as f64; + + // Drain commands from the editor + let commands: Vec = self.bridge.cmd_rx.try_iter().collect(); + + let input = TickInput { + commands, + playing, + beat, + lookahead_end, + tempo: effective_tempo, + quantum, + fill: false, + nudge_secs: 0.0, + current_time_us: 0, + engine_time, + mouse_x: 0.5, + mouse_y: 0.5, + mouse_down: 0.0, + }; + + let output = seq_state.tick(input); + + // Publish snapshot for the editor + self.bridge + .shared_state + .store(Arc::new(output.shared_state)); + + // Drain audio commands from the editor (preview, hush, load samples, etc.) + for audio_cmd in self.bridge.audio_cmd_rx.try_iter() { + match audio_cmd { + AudioCommand::Evaluate { ref cmd, time } => { + let cmd_ref = match time { + Some(t) => { + self.cmd_buffer.clear(); + use std::fmt::Write; + let _ = write!(&mut self.cmd_buffer, "{cmd}/time/{t:.6}"); + self.cmd_buffer.as_str() + } + None => cmd.as_str(), + }; + engine.evaluate(cmd_ref); + } + AudioCommand::Hush => engine.hush(), + AudioCommand::Panic => engine.panic(), + AudioCommand::LoadSamples(samples) => { + engine.sample_index.extend(samples); + } + } + } + + // Drain expired pending note-offs + self.pending_note_offs.retain(|off| { + if off.target_sample <= self.sample_pos { + context.send_event(NoteEvent::NoteOff { + timing: 0, + voice_id: None, + channel: off.channel, + note: off.note, + velocity: 0.0, + }); + false + } else { + true + } + }); + + // Feed audio + MIDI commands from sequencer + for tsc in &output.audio_commands { + if tsc.cmd.starts_with("/midi/") { + if let Some((midi_cmd, dur, _delta)) = parse_midi_command(&tsc.cmd) { + match midi_cmd { + MidiCommand::NoteOn { channel, note, velocity, .. } => { + context.send_event(NoteEvent::NoteOn { + timing: 0, + voice_id: None, + channel, + note, + velocity: velocity as f32 / 127.0, + }); + if let Some(dur) = dur { + self.pending_note_offs.push(PendingNoteOff { + target_sample: self.sample_pos + + (dur * self.sample_rate as f64) as u64, + channel, + note, + }); + } + } + MidiCommand::NoteOff { channel, note, .. } => { + context.send_event(NoteEvent::NoteOff { + timing: 0, + voice_id: None, + channel, + note, + velocity: 0.0, + }); + } + MidiCommand::CC { channel, cc, value, .. } => { + context.send_event(NoteEvent::MidiCC { + timing: 0, + channel, + cc, + value: value as f32 / 127.0, + }); + } + MidiCommand::PitchBend { channel, value, .. } => { + context.send_event(NoteEvent::MidiPitchBend { + timing: 0, + channel, + value: value as f32 / 16383.0, + }); + } + MidiCommand::Pressure { channel, value, .. } => { + context.send_event(NoteEvent::MidiChannelPressure { + timing: 0, + channel, + pressure: value as f32 / 127.0, + }); + } + MidiCommand::ProgramChange { channel, program, .. } => { + context.send_event(NoteEvent::MidiProgramChange { + timing: 0, + channel, + program, + }); + } + MidiCommand::Clock { .. } + | MidiCommand::Start { .. } + | MidiCommand::Stop { .. } + | MidiCommand::Continue { .. } => {} + } + } + continue; + } + let cmd_ref = match tsc.time { + Some(t) => { + self.cmd_buffer.clear(); + use std::fmt::Write; + let _ = write!(&mut self.cmd_buffer, "{}/time/{t:.6}", tsc.cmd); + self.cmd_buffer.as_str() + } + None => &tsc.cmd, + }; + engine.evaluate(cmd_ref); + } + + // Process audio block — doux writes interleaved stereo into our buffer + let num_samples = buffer_len * 2; + self.audio_buffer.resize(num_samples, 0.0); + self.audio_buffer.fill(0.0); + engine.process_block(&mut self.audio_buffer, &[], &[]); + + // Feed scope and spectrum analysis + self.bridge.scope_buffer.write(&self.audio_buffer); + if let Some(producer) = &mut self.fft_producer { + for chunk in self.audio_buffer.chunks(2) { + let mono = (chunk[0] + chunk.get(1).copied().unwrap_or(0.0)) * 0.5; + let _ = producer.try_push(mono); + } + } + + // Copy interleaved doux output → nih-plug channel slices + let mut channel_iter = buffer.iter_samples(); + for frame_idx in 0..buffer_len { + if let Some(mut frame) = channel_iter.next() { + let left = self.audio_buffer[frame_idx * 2]; + let right = self.audio_buffer[frame_idx * 2 + 1]; + if let Some(sample) = frame.get_mut(0) { + *sample = left; + } + if let Some(sample) = frame.get_mut(1) { + *sample = right; + } + } + } + + self.sample_pos += buffer_len as u64; + self.prev_beat = lookahead_end; + + ProcessStatus::Normal + } +} + +impl ClapPlugin for CagirePlugin { + const CLAP_ID: &'static str = "com.sova.cagire"; + const CLAP_DESCRIPTION: Option<&'static str> = Some("Forth-based music sequencer"); + const CLAP_MANUAL_URL: Option<&'static str> = Some("https://cagire.raphaelforment.fr"); + const CLAP_SUPPORT_URL: Option<&'static str> = Some("https://cagire.raphaelforment.fr"); + const CLAP_FEATURES: &'static [ClapFeature] = &[ + ClapFeature::Instrument, + ClapFeature::Synthesizer, + ClapFeature::Stereo, + ]; +} + +impl Vst3Plugin for CagirePlugin { + const VST3_CLASS_ID: [u8; 16] = *b"CagireSovaVST3!!"; + const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[ + Vst3SubCategory::Instrument, + Vst3SubCategory::Synth, + Vst3SubCategory::Stereo, + ]; +} + +nih_export_clap!(CagirePlugin); +nih_export_vst3!(CagirePlugin); diff --git a/crates/clap/src/main.rs b/crates/clap/src/main.rs new file mode 100644 index 0000000..a811ab8 --- /dev/null +++ b/crates/clap/src/main.rs @@ -0,0 +1,12 @@ +use cagire_clap::CagirePlugin; +use nih_plug::prelude::*; + +fn main() { + let mut args: Vec = std::env::args().collect(); + // Default to 44100 Hz — nih-plug defaults to 48000 which causes CoreAudio + // to deliver mismatched buffer sizes, crashing the standalone wrapper. + if !args.iter().any(|a| a == "--sample-rate" || a == "-r") { + args.extend(["--sample-rate".into(), "44100".into()]); + } + nih_export_standalone_with_args::(args); +} diff --git a/crates/clap/src/params.rs b/crates/clap/src/params.rs new file mode 100644 index 0000000..9d15855 --- /dev/null +++ b/crates/clap/src/params.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use cagire_project::Project; +use nih_plug::prelude::*; +use nih_plug_egui::EguiState; +use parking_lot::Mutex; + +#[derive(Params)] +pub struct CagireParams { + #[persist = "editor-state"] + pub editor_state: Arc, + + #[persist = "project"] + pub project: Arc>, + + #[id = "tempo"] + pub tempo: FloatParam, + + #[id = "sync"] + pub sync_to_host: BoolParam, + + #[id = "bank"] + pub bank: IntParam, + + #[id = "pattern"] + pub pattern: IntParam, +} + +impl Default for CagireParams { + fn default() -> Self { + Self { + editor_state: EguiState::from_size(1200, 800), + project: Arc::new(Mutex::new(Project::default())), + + tempo: FloatParam::new("Tempo", 120.0, FloatRange::Linear { min: 40.0, max: 300.0 }) + .with_unit(" BPM") + .with_step_size(0.1), + + sync_to_host: BoolParam::new("Sync to Host", true), + + bank: IntParam::new("Bank", 0, IntRange::Linear { min: 0, max: 31 }), + + pattern: IntParam::new("Pattern", 0, IntRange::Linear { min: 0, max: 31 }), + } + } +} diff --git a/crates/egui-baseview/Cargo.toml b/crates/egui-baseview/Cargo.toml new file mode 100644 index 0000000..59d6caf --- /dev/null +++ b/crates/egui-baseview/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "egui-baseview" +version = "0.7.0" +authors = ["Billy Messenger <60663878+BillyDM@users.noreply.github.com>"] +description = "A baseview backend for egui" +edition = "2021" +license = "MIT" + +[features] +default = ["opengl", "default_fonts", "tracing"] +default_fonts = ["egui/default_fonts"] +opengl = ["dep:egui_glow", "baseview/opengl"] +tracing = ["dep:tracing"] + +[dependencies] +baseview = { git = "https://github.com/RustAudio/baseview.git", rev = "237d323c729f3aa99476ba3efa50129c5e86cad3" } +raw-window-handle = "0.5" +egui = { version = "0.33", default-features = false, features = ["bytemuck"] } +egui_glow = { version = "0.33", features = ["x11"], optional = true } +keyboard-types = { version = "0.6", default-features = false } +copypasta = { version = "0.10", default-features = false, features = ["x11"] } +tracing = { version = "0.1", optional = true } +open = "5.1" +thiserror = "2.0" diff --git a/crates/egui-baseview/LICENSE b/crates/egui-baseview/LICENSE new file mode 100644 index 0000000..1632cb7 --- /dev/null +++ b/crates/egui-baseview/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Billy Messenger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/egui-baseview/src/lib.rs b/crates/egui-baseview/src/lib.rs new file mode 100644 index 0000000..c49e4aa --- /dev/null +++ b/crates/egui-baseview/src/lib.rs @@ -0,0 +1,10 @@ +mod renderer; +mod translate; +mod window; + +pub use window::{EguiWindow, KeyCapture, Queue}; + +pub use egui; +pub use renderer::GraphicsConfig; + +pub use keyboard_types::Key; diff --git a/crates/egui-baseview/src/renderer.rs b/crates/egui-baseview/src/renderer.rs new file mode 100644 index 0000000..23a63ec --- /dev/null +++ b/crates/egui-baseview/src/renderer.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "opengl")] +mod opengl; +#[cfg(feature = "opengl")] +pub use opengl::renderer::{GraphicsConfig, Renderer}; diff --git a/crates/egui-baseview/src/renderer/opengl.rs b/crates/egui-baseview/src/renderer/opengl.rs new file mode 100644 index 0000000..5c91ac7 --- /dev/null +++ b/crates/egui-baseview/src/renderer/opengl.rs @@ -0,0 +1,12 @@ +use egui_glow::PainterError; +use thiserror::Error; + +pub mod renderer; + +#[derive(Error, Debug)] +pub enum OpenGlError { + #[error("Failed to get baseview's GL context")] + NoContext, + #[error("Error occured when initializing painter: \n {0}")] + CreatePainter(PainterError), +} diff --git a/crates/egui-baseview/src/renderer/opengl/renderer.rs b/crates/egui-baseview/src/renderer/opengl/renderer.rs new file mode 100644 index 0000000..00407f5 --- /dev/null +++ b/crates/egui-baseview/src/renderer/opengl/renderer.rs @@ -0,0 +1,130 @@ +use baseview::{PhySize, Window}; +use egui::FullOutput; +use egui_glow::Painter; +use std::sync::Arc; + +use super::OpenGlError; + +#[derive(Debug, Clone)] +pub struct GraphicsConfig { + /// Controls whether to apply dithering to minimize banding artifacts. + /// + /// Dithering assumes an sRGB output and thus will apply noise to any input value that lies between + /// two 8bit values after applying the sRGB OETF function, i.e. if it's not a whole 8bit value in "gamma space". + /// This means that only inputs from texture interpolation and vertex colors should be affected in practice. + /// + /// Defaults to true. + pub dithering: bool, + + /// Needed for cross compiling for VirtualBox VMSVGA driver with OpenGL ES 2.0 and OpenGL 2.1 which doesn't support SRGB texture. + /// See . + /// + /// For OpenGL ES 2.0: set this to [`egui_glow::ShaderVersion::Es100`] to solve blank texture problem (by using the "fallback shader"). + pub shader_version: Option, +} + +impl Default for GraphicsConfig { + fn default() -> Self { + Self { + shader_version: None, + dithering: true, + } + } +} + +pub struct Renderer { + glow_context: Arc, + painter: Painter, +} + +impl Renderer { + pub fn new(window: &Window, config: GraphicsConfig) -> Result { + let context = window.gl_context().ok_or(OpenGlError::NoContext)?; + unsafe { + context.make_current(); + } + + #[allow(clippy::arc_with_non_send_sync)] + let glow_context = Arc::new(unsafe { + egui_glow::glow::Context::from_loader_function(|s| context.get_proc_address(s)) + }); + + let painter = egui_glow::Painter::new( + Arc::clone(&glow_context), + "", + config.shader_version, + config.dithering, + ) + .map_err(OpenGlError::CreatePainter)?; + + unsafe { + context.make_not_current(); + } + + Ok(Self { + glow_context, + painter, + }) + } + + pub fn max_texture_side(&self) -> usize { + self.painter.max_texture_side() + } + + pub fn render( + &mut self, + window: &Window, + bg_color: egui::Rgba, + physical_size: PhySize, + pixels_per_point: f32, + egui_ctx: &mut egui::Context, + full_output: &mut FullOutput, + ) { + let PhySize { + width: canvas_width, + height: canvas_height, + } = physical_size; + + let shapes = std::mem::take(&mut full_output.shapes); + let textures_delta = &mut full_output.textures_delta; + + let context = window + .gl_context() + .expect("failed to get baseview gl context"); + unsafe { + context.make_current(); + } + + unsafe { + use egui_glow::glow::HasContext as _; + self.glow_context + .clear_color(bg_color.r(), bg_color.g(), bg_color.b(), bg_color.a()); + self.glow_context.clear(egui_glow::glow::COLOR_BUFFER_BIT); + } + + for (id, image_delta) in &textures_delta.set { + self.painter.set_texture(*id, image_delta); + } + + let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point); + let dimensions: [u32; 2] = [canvas_width, canvas_height]; + + self.painter + .paint_primitives(dimensions, pixels_per_point, &clipped_primitives); + + for id in textures_delta.free.drain(..) { + self.painter.free_texture(id); + } + + unsafe { + context.swap_buffers(); + context.make_not_current(); + } + } +} + +impl Drop for Renderer { + fn drop(&mut self) { + self.painter.destroy() + } +} diff --git a/crates/egui-baseview/src/translate.rs b/crates/egui-baseview/src/translate.rs new file mode 100644 index 0000000..57a079a --- /dev/null +++ b/crates/egui-baseview/src/translate.rs @@ -0,0 +1,118 @@ +pub(crate) fn translate_mouse_button(button: baseview::MouseButton) -> Option { + match button { + baseview::MouseButton::Left => Some(egui::PointerButton::Primary), + baseview::MouseButton::Right => Some(egui::PointerButton::Secondary), + baseview::MouseButton::Middle => Some(egui::PointerButton::Middle), + _ => None, + } +} + +pub(crate) fn translate_virtual_key(key: &keyboard_types::Key) -> Option { + use egui::Key; + use keyboard_types::Key as K; + + Some(match key { + K::ArrowDown => Key::ArrowDown, + K::ArrowLeft => Key::ArrowLeft, + K::ArrowRight => Key::ArrowRight, + K::ArrowUp => Key::ArrowUp, + + K::Escape => Key::Escape, + K::Tab => Key::Tab, + K::Backspace => Key::Backspace, + K::Enter => Key::Enter, + + K::Insert => Key::Insert, + K::Delete => Key::Delete, + K::Home => Key::Home, + K::End => Key::End, + K::PageUp => Key::PageUp, + K::PageDown => Key::PageDown, + + K::Character(s) => match s.chars().next()? { + ' ' => Key::Space, + '0' => Key::Num0, + '1' => Key::Num1, + '2' => Key::Num2, + '3' => Key::Num3, + '4' => Key::Num4, + '5' => Key::Num5, + '6' => Key::Num6, + '7' => Key::Num7, + '8' => Key::Num8, + '9' => Key::Num9, + 'a' => Key::A, + 'b' => Key::B, + 'c' => Key::C, + 'd' => Key::D, + 'e' => Key::E, + 'f' => Key::F, + 'g' => Key::G, + 'h' => Key::H, + 'i' => Key::I, + 'j' => Key::J, + 'k' => Key::K, + 'l' => Key::L, + 'm' => Key::M, + 'n' => Key::N, + 'o' => Key::O, + 'p' => Key::P, + 'q' => Key::Q, + 'r' => Key::R, + 's' => Key::S, + 't' => Key::T, + 'u' => Key::U, + 'v' => Key::V, + 'w' => Key::W, + 'x' => Key::X, + 'y' => Key::Y, + 'z' => Key::Z, + _ => { + return None; + } + }, + _ => { + return None; + } + }) +} + +pub(crate) fn translate_cursor_icon(cursor: egui::CursorIcon) -> baseview::MouseCursor { + match cursor { + egui::CursorIcon::Default => baseview::MouseCursor::Default, + egui::CursorIcon::None => baseview::MouseCursor::Hidden, + egui::CursorIcon::ContextMenu => baseview::MouseCursor::Hand, + egui::CursorIcon::Help => baseview::MouseCursor::Help, + egui::CursorIcon::PointingHand => baseview::MouseCursor::Hand, + egui::CursorIcon::Progress => baseview::MouseCursor::PtrWorking, + egui::CursorIcon::Wait => baseview::MouseCursor::Working, + egui::CursorIcon::Cell => baseview::MouseCursor::Cell, + egui::CursorIcon::Crosshair => baseview::MouseCursor::Crosshair, + egui::CursorIcon::Text => baseview::MouseCursor::Text, + egui::CursorIcon::VerticalText => baseview::MouseCursor::VerticalText, + egui::CursorIcon::Alias => baseview::MouseCursor::Alias, + egui::CursorIcon::Copy => baseview::MouseCursor::Copy, + egui::CursorIcon::Move => baseview::MouseCursor::Move, + egui::CursorIcon::NoDrop => baseview::MouseCursor::NotAllowed, + egui::CursorIcon::NotAllowed => baseview::MouseCursor::NotAllowed, + egui::CursorIcon::Grab => baseview::MouseCursor::Hand, + egui::CursorIcon::Grabbing => baseview::MouseCursor::HandGrabbing, + egui::CursorIcon::AllScroll => baseview::MouseCursor::AllScroll, + egui::CursorIcon::ResizeHorizontal => baseview::MouseCursor::EwResize, + egui::CursorIcon::ResizeNeSw => baseview::MouseCursor::NeswResize, + egui::CursorIcon::ResizeNwSe => baseview::MouseCursor::NwseResize, + egui::CursorIcon::ResizeVertical => baseview::MouseCursor::NsResize, + egui::CursorIcon::ResizeEast => baseview::MouseCursor::EResize, + egui::CursorIcon::ResizeSouthEast => baseview::MouseCursor::SeResize, + egui::CursorIcon::ResizeSouth => baseview::MouseCursor::SResize, + egui::CursorIcon::ResizeSouthWest => baseview::MouseCursor::SwResize, + egui::CursorIcon::ResizeWest => baseview::MouseCursor::WResize, + egui::CursorIcon::ResizeNorthWest => baseview::MouseCursor::NwResize, + egui::CursorIcon::ResizeNorth => baseview::MouseCursor::NResize, + egui::CursorIcon::ResizeNorthEast => baseview::MouseCursor::NeResize, + egui::CursorIcon::ResizeColumn => baseview::MouseCursor::ColResize, + egui::CursorIcon::ResizeRow => baseview::MouseCursor::RowResize, + egui::CursorIcon::ZoomIn => baseview::MouseCursor::ZoomIn, + egui::CursorIcon::ZoomOut => baseview::MouseCursor::ZoomOut, + } +} diff --git a/crates/egui-baseview/src/window.rs b/crates/egui-baseview/src/window.rs new file mode 100644 index 0000000..edbbea0 --- /dev/null +++ b/crates/egui-baseview/src/window.rs @@ -0,0 +1,685 @@ +use std::time::Instant; + +use baseview::{ + Event, EventStatus, PhySize, Window, WindowHandle, WindowHandler, WindowOpenOptions, + WindowScalePolicy, +}; +use copypasta::ClipboardProvider; +use egui::{pos2, vec2, Pos2, Rect, Rgba, ViewportCommand}; +use keyboard_types::Modifiers; +use raw_window_handle::HasRawWindowHandle; + +use crate::{renderer::Renderer, GraphicsConfig}; + +#[cfg(feature = "tracing")] +use tracing::{error, warn}; + +pub struct Queue<'a> { + bg_color: &'a mut Rgba, + close_requested: &'a mut bool, + physical_size: &'a mut PhySize, + key_capture: &'a mut KeyCapture, +} + +impl<'a> Queue<'a> { + pub(crate) fn new( + bg_color: &'a mut Rgba, + close_requested: &'a mut bool, + physical_size: &'a mut PhySize, + key_capture: &'a mut KeyCapture, + ) -> Self { + Self { + bg_color, + //renderer, + //repaint_requested, + close_requested, + physical_size, + key_capture, + } + } + + /// Set the background color. + pub fn bg_color(&mut self, bg_color: Rgba) { + *self.bg_color = bg_color; + } + + /// Set size of the window. + pub fn resize(&mut self, physical_size: PhySize) { + *self.physical_size = physical_size; + } + + /// Close the window. + pub fn close_window(&mut self) { + *self.close_requested = true; + } + + /// Set how to handle capturing key events from the host. + pub fn set_key_capture(&mut self, key_capture: KeyCapture) { + *self.key_capture = key_capture; + } +} + +struct OpenSettings { + scale_policy: WindowScalePolicy, + logical_width: f64, + logical_height: f64, + title: String, +} + +impl OpenSettings { + fn new(settings: &WindowOpenOptions) -> Self { + // WindowScalePolicy does not implement copy/clone. + let scale_policy = match &settings.scale { + WindowScalePolicy::SystemScaleFactor => WindowScalePolicy::SystemScaleFactor, + WindowScalePolicy::ScaleFactor(scale) => WindowScalePolicy::ScaleFactor(*scale), + }; + + Self { + scale_policy, + logical_width: settings.size.width, + logical_height: settings.size.height, + title: settings.title.clone(), + } + } +} + +/// Describes how to handle capturing key events from the host. +#[derive(Default, Debug, Clone, PartialEq)] +pub enum KeyCapture { + #[default] + /// All keys will be captured from the host. + CaptureAll, + /// No keys will be captured from the host. + IgnoreAll, + /// Only the given keys will be captured from the host. + CaptureKeys(Vec), + /// All keys except the given ones will be captured from the host. + IgnoreKeys(Vec), +} + +/// Handles an egui-baseview application +pub struct EguiWindow +where + State: 'static + Send, + U: FnMut(&egui::Context, &mut Queue, &mut State), + U: 'static + Send, +{ + user_state: Option, + user_update: U, + + egui_ctx: egui::Context, + viewport_id: egui::ViewportId, + start_time: Instant, + egui_input: egui::RawInput, + pointer_pos_in_points: Option, + current_cursor_icon: baseview::MouseCursor, + + renderer: Renderer, + + clipboard_ctx: Option, + + physical_size: PhySize, + scale_policy: WindowScalePolicy, + pixels_per_point: f32, + points_per_pixel: f32, + bg_color: Rgba, + close_requested: bool, + repaint_after: Option, + key_capture: KeyCapture, +} + +impl EguiWindow +where + State: 'static + Send, + U: FnMut(&egui::Context, &mut Queue, &mut State), + U: 'static + Send, +{ + fn new( + window: &mut baseview::Window<'_>, + open_settings: OpenSettings, + graphics_config: GraphicsConfig, + mut build: B, + update: U, + mut state: State, + ) -> EguiWindow + where + B: FnMut(&egui::Context, &mut Queue, &mut State), + B: 'static + Send, + { + let renderer = Renderer::new(window, graphics_config).unwrap_or_else(|err| { + // TODO: better error log and not panicking, but that's gonna require baseview changes + error!("oops! the gpu backend couldn't initialize! \n {err}"); + panic!("gpu backend failed to initialize: \n {err}") + }); + let egui_ctx = egui::Context::default(); + + // Assume scale for now until there is an event with a new one. + let pixels_per_point = match open_settings.scale_policy { + WindowScalePolicy::ScaleFactor(scale) => scale, + WindowScalePolicy::SystemScaleFactor => 1.0, + } as f32; + let points_per_pixel = pixels_per_point.recip(); + + let screen_rect = Rect::from_min_size( + Pos2::new(0f32, 0f32), + vec2( + open_settings.logical_width as f32, + open_settings.logical_height as f32, + ), + ); + + let viewport_info = egui::ViewportInfo { + parent: None, + title: Some(open_settings.title), + native_pixels_per_point: Some(pixels_per_point), + focused: Some(true), + inner_rect: Some(screen_rect), + ..Default::default() + }; + let viewport_id = egui::ViewportId::default(); + + let mut egui_input = egui::RawInput { + max_texture_side: Some(renderer.max_texture_side()), + screen_rect: Some(screen_rect), + ..Default::default() + }; + let _ = egui_input.viewports.insert(viewport_id, viewport_info); + + let mut physical_size = PhySize { + width: (open_settings.logical_width * pixels_per_point as f64).round() as u32, + height: (open_settings.logical_height * pixels_per_point as f64).round() as u32, + }; + + let mut bg_color = Rgba::BLACK; + let mut close_requested = false; + let mut key_capture = KeyCapture::default(); + let mut queue = Queue::new( + &mut bg_color, + &mut close_requested, + &mut physical_size, + &mut key_capture, + ); + (build)(&egui_ctx, &mut queue, &mut state); + + let clipboard_ctx = match copypasta::ClipboardContext::new() { + Ok(clipboard_ctx) => Some(clipboard_ctx), + Err(e) => { + error!("Failed to initialize clipboard: {}", e); + None + } + }; + + let start_time = Instant::now(); + + Self { + user_state: Some(state), + user_update: update, + + egui_ctx, + viewport_id, + start_time, + egui_input, + pointer_pos_in_points: None, + current_cursor_icon: baseview::MouseCursor::Default, + + renderer, + + clipboard_ctx, + + physical_size, + pixels_per_point, + points_per_pixel, + scale_policy: open_settings.scale_policy, + bg_color, + close_requested, + repaint_after: Some(start_time), + key_capture, + } + } + + /// Open a new child window. + /// + /// * `parent` - The parent window. + /// * `settings` - The settings of the window. + /// * `state` - The initial state of your application. + /// * `build` - Called once before the first frame. Allows you to do setup code and to + /// call `ctx.set_fonts()`. Optional. + /// * `update` - Called before each frame. Here you should update the state of your + /// application and build the UI. + pub fn open_parented( + parent: &P, + #[allow(unused_mut)] mut settings: WindowOpenOptions, + graphics_config: GraphicsConfig, + state: State, + build: B, + update: U, + ) -> WindowHandle + where + P: HasRawWindowHandle, + B: FnMut(&egui::Context, &mut Queue, &mut State), + B: 'static + Send, + { + #[cfg(feature = "opengl")] + if settings.gl_config.is_none() { + settings.gl_config = Some(Default::default()); + } + + let open_settings = OpenSettings::new(&settings); + + Window::open_parented( + parent, + settings, + move |window: &mut baseview::Window<'_>| -> EguiWindow { + EguiWindow::new(window, open_settings, graphics_config, build, update, state) + }, + ) + } + + /// Open a new window that blocks the current thread until the window is destroyed. + /// + /// * `settings` - The settings of the window. + /// * `state` - The initial state of your application. + /// * `build` - Called once before the first frame. Allows you to do setup code and to + /// call `ctx.set_fonts()`. Optional. + /// * `update` - Called before each frame. Here you should update the state of your + /// application and build the UI. + pub fn open_blocking( + #[allow(unused_mut)] mut settings: WindowOpenOptions, + graphics_config: GraphicsConfig, + state: State, + build: B, + update: U, + ) where + B: FnMut(&egui::Context, &mut Queue, &mut State), + B: 'static + Send, + { + #[cfg(feature = "opengl")] + if settings.gl_config.is_none() { + settings.gl_config = Some(Default::default()); + } + + let open_settings = OpenSettings::new(&settings); + + Window::open_blocking( + settings, + move |window: &mut baseview::Window<'_>| -> EguiWindow { + EguiWindow::new(window, open_settings, graphics_config, build, update, state) + }, + ) + } + + /// Update the pressed key modifiers when a mouse event has sent a new set of modifiers. + fn update_modifiers(&mut self, modifiers: &Modifiers) { + self.egui_input.modifiers.alt = !(*modifiers & Modifiers::ALT).is_empty(); + self.egui_input.modifiers.shift = !(*modifiers & Modifiers::SHIFT).is_empty(); + self.egui_input.modifiers.command = !(*modifiers & Modifiers::CONTROL).is_empty(); + } +} + +impl WindowHandler for EguiWindow +where + State: 'static + Send, + U: FnMut(&egui::Context, &mut Queue, &mut State), + U: 'static + Send, +{ + fn on_frame(&mut self, window: &mut Window) { + let Some(state) = &mut self.user_state else { + return; + }; + + self.egui_input.time = Some(self.start_time.elapsed().as_secs_f64()); + self.egui_input.screen_rect = Some(calculate_screen_rect( + self.physical_size, + self.points_per_pixel, + )); + + self.egui_ctx.begin_pass(self.egui_input.take()); + + //let mut repaint_requested = false; + let mut queue = Queue::new( + &mut self.bg_color, + &mut self.close_requested, + &mut self.physical_size, + &mut self.key_capture, + ); + + (self.user_update)(&self.egui_ctx, &mut queue, state); + + if self.close_requested { + window.close(); + } + + // Prevent data from being allocated every frame by storing this + // in a member field. + let mut full_output = self.egui_ctx.end_pass(); + + let Some(viewport_output) = full_output.viewport_output.get(&self.viewport_id) else { + // The main window was closed by egui. + window.close(); + return; + }; + + for command in viewport_output.commands.iter() { + match command { + ViewportCommand::Close => { + window.close(); + } + ViewportCommand::InnerSize(size) => window.resize(baseview::Size { + width: size.x.max(1.0) as f64, + height: size.y.max(1.0) as f64, + }), + _ => {} + } + } + + let now = Instant::now(); + let do_repaint_now = if let Some(t) = self.repaint_after { + now >= t || viewport_output.repaint_delay.is_zero() + } else { + viewport_output.repaint_delay.is_zero() + }; + + if do_repaint_now { + self.renderer.render( + #[cfg(feature = "opengl")] + window, + self.bg_color, + self.physical_size, + self.pixels_per_point, + &mut self.egui_ctx, + &mut full_output, + ); + + self.repaint_after = None; + } else if let Some(repaint_after) = now.checked_add(viewport_output.repaint_delay) { + // Schedule to repaint after the requested time has elapsed. + self.repaint_after = Some(repaint_after); + } + + for command in full_output.platform_output.commands { + match command { + egui::OutputCommand::CopyText(text) => { + if let Some(clipboard_ctx) = &mut self.clipboard_ctx { + if let Err(err) = clipboard_ctx.set_contents(text) { + error!("Copy/Cut error: {}", err); + } + } + } + egui::OutputCommand::CopyImage(_) => { + warn!("Copying images is not supported in egui_baseview."); + } + egui::OutputCommand::OpenUrl(open_url) => { + if let Err(err) = open::that_detached(&open_url.url) { + error!("Open error: {}", err); + } + } + } + } + + let cursor_icon = + crate::translate::translate_cursor_icon(full_output.platform_output.cursor_icon); + if self.current_cursor_icon != cursor_icon { + self.current_cursor_icon = cursor_icon; + + // TODO: Set mouse cursor for MacOS once baseview supports it. + #[cfg(not(target_os = "macos"))] + window.set_mouse_cursor(cursor_icon); + } + + } + + fn on_event(&mut self, _window: &mut Window, event: Event) -> EventStatus { + let mut return_status = EventStatus::Captured; + + match &event { + baseview::Event::Mouse(event) => match event { + baseview::MouseEvent::CursorMoved { + position, + modifiers, + } => { + self.update_modifiers(modifiers); + + let pos = pos2(position.x as f32, position.y as f32); + self.pointer_pos_in_points = Some(pos); + self.egui_input.events.push(egui::Event::PointerMoved(pos)); + } + baseview::MouseEvent::ButtonPressed { button, modifiers } => { + self.update_modifiers(modifiers); + + if let Some(pos) = self.pointer_pos_in_points { + if let Some(button) = crate::translate::translate_mouse_button(*button) { + self.egui_input.events.push(egui::Event::PointerButton { + pos, + button, + pressed: true, + modifiers: self.egui_input.modifiers, + }); + } + } + } + baseview::MouseEvent::ButtonReleased { button, modifiers } => { + self.update_modifiers(modifiers); + + if let Some(pos) = self.pointer_pos_in_points { + if let Some(button) = crate::translate::translate_mouse_button(*button) { + self.egui_input.events.push(egui::Event::PointerButton { + pos, + button, + pressed: false, + modifiers: self.egui_input.modifiers, + }); + } + } + } + baseview::MouseEvent::WheelScrolled { + delta: scroll_delta, + modifiers, + } => { + self.update_modifiers(modifiers); + + #[allow(unused_mut)] + let (unit, mut delta) = match scroll_delta { + baseview::ScrollDelta::Lines { x, y } => { + (egui::MouseWheelUnit::Line, egui::vec2(*x, *y)) + } + + baseview::ScrollDelta::Pixels { x, y } => ( + egui::MouseWheelUnit::Point, + egui::vec2(*x, *y) * self.points_per_pixel, + ), + }; + + if cfg!(target_os = "macos") { + // This is still buggy in winit despite + // https://github.com/rust-windowing/winit/issues/1695 being closed + // + // TODO: See if this is an issue in baseview as well. + delta.x *= -1.0; + } + + self.egui_input.events.push(egui::Event::MouseWheel { + unit, + delta, + modifiers: self.egui_input.modifiers, + }); + } + baseview::MouseEvent::CursorLeft => { + self.pointer_pos_in_points = None; + self.egui_input.events.push(egui::Event::PointerGone); + } + _ => {} + }, + baseview::Event::Keyboard(event) => { + use keyboard_types::Code; + + let pressed = event.state == keyboard_types::KeyState::Down; + + match event.code { + Code::ShiftLeft | Code::ShiftRight => self.egui_input.modifiers.shift = pressed, + Code::ControlLeft | Code::ControlRight => { + self.egui_input.modifiers.ctrl = pressed; + + #[cfg(not(target_os = "macos"))] + { + self.egui_input.modifiers.command = pressed; + } + } + Code::AltLeft | Code::AltRight => self.egui_input.modifiers.alt = pressed, + Code::MetaLeft | Code::MetaRight => { + #[cfg(target_os = "macos")] + { + self.egui_input.modifiers.mac_cmd = pressed; + self.egui_input.modifiers.command = pressed; + } + // prevent `rustfmt` from breaking this + } + _ => (), + } + + if let Some(key) = crate::translate::translate_virtual_key(&event.key) { + self.egui_input.events.push(egui::Event::Key { + key, + physical_key: None, + pressed, + repeat: event.repeat, + modifiers: self.egui_input.modifiers, + }); + } + + if pressed { + // VirtualKeyCode::Paste etc in winit are broken/untrustworthy, + // so we detect these things manually: + // + // TODO: See if this is an issue in baseview as well. + if is_cut_command(self.egui_input.modifiers, event.code) { + self.egui_input.events.push(egui::Event::Cut); + } else if is_copy_command(self.egui_input.modifiers, event.code) { + self.egui_input.events.push(egui::Event::Copy); + } else if is_paste_command(self.egui_input.modifiers, event.code) { + if let Some(clipboard_ctx) = &mut self.clipboard_ctx { + match clipboard_ctx.get_contents() { + Ok(contents) => { + self.egui_input.events.push(egui::Event::Text(contents)) + } + Err(err) => { + error!("Paste error: {}", err); + } + } + } + } else if let keyboard_types::Key::Character(written) = &event.key { + if !self.egui_input.modifiers.ctrl && !self.egui_input.modifiers.command { + self.egui_input + .events + .push(egui::Event::Text(written.clone())); + } + } + } + + match &self.key_capture { + KeyCapture::CaptureAll => {} + KeyCapture::IgnoreAll => return_status = EventStatus::Ignored, + KeyCapture::CaptureKeys(keys) => { + if !keys.contains(&event.key) { + return_status = EventStatus::Ignored + } + } + KeyCapture::IgnoreKeys(keys) => { + if keys.contains(&event.key) { + return_status = EventStatus::Ignored + } + } + } + } + baseview::Event::Window(event) => match event { + baseview::WindowEvent::Resized(window_info) => { + self.pixels_per_point = match self.scale_policy { + WindowScalePolicy::ScaleFactor(scale) => scale, + WindowScalePolicy::SystemScaleFactor => window_info.scale(), + } as f32; + self.points_per_pixel = self.pixels_per_point.recip(); + + self.physical_size = window_info.physical_size(); + + let screen_rect = + calculate_screen_rect(self.physical_size, self.points_per_pixel); + + self.egui_input.screen_rect = Some(screen_rect); + + let viewport_info = self + .egui_input + .viewports + .get_mut(&self.viewport_id) + .unwrap(); + viewport_info.native_pixels_per_point = Some(self.pixels_per_point); + viewport_info.inner_rect = Some(screen_rect); + + // Schedule to repaint on the next frame. + self.repaint_after = Some(Instant::now()); + } + baseview::WindowEvent::Focused => { + self.egui_input + .events + .push(egui::Event::WindowFocused(true)); + self.egui_input + .viewports + .get_mut(&self.viewport_id) + .unwrap() + .focused = Some(true); + } + baseview::WindowEvent::Unfocused => { + self.egui_input + .events + .push(egui::Event::WindowFocused(false)); + self.egui_input + .viewports + .get_mut(&self.viewport_id) + .unwrap() + .focused = Some(false); + } + baseview::WindowEvent::WillClose => {} + }, + } + + match &event { + baseview::Event::Keyboard(_) => return_status, + baseview::Event::Mouse(_) => { + if self.egui_ctx.is_using_pointer() || self.egui_ctx.wants_pointer_input() { + EventStatus::Captured + } else { + EventStatus::Ignored + } + } + baseview::Event::Window(_) => EventStatus::Captured, + } + } +} + +fn is_cut_command(modifiers: egui::Modifiers, keycode: keyboard_types::Code) -> bool { + (modifiers.command && keycode == keyboard_types::Code::KeyX) + || (cfg!(target_os = "windows") + && modifiers.shift + && keycode == keyboard_types::Code::Delete) +} + +fn is_copy_command(modifiers: egui::Modifiers, keycode: keyboard_types::Code) -> bool { + (modifiers.command && keycode == keyboard_types::Code::KeyC) + || (cfg!(target_os = "windows") + && modifiers.ctrl + && keycode == keyboard_types::Code::Insert) +} + +fn is_paste_command(modifiers: egui::Modifiers, keycode: keyboard_types::Code) -> bool { + (modifiers.command && keycode == keyboard_types::Code::KeyV) + || (cfg!(target_os = "windows") + && modifiers.shift + && keycode == keyboard_types::Code::Insert) +} + +/// Calculate screen rectangle in logical size. +fn calculate_screen_rect(physical_size: PhySize, points_per_pixel: f32) -> Rect { + let logical_size = ( + physical_size.width as f32 * points_per_pixel, + physical_size.height as f32 * points_per_pixel, + ); + Rect::from_min_size(Pos2::new(0f32, 0f32), vec2(logical_size.0, logical_size.1)) +} diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index f6b8131..ba65598 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -60,11 +60,8 @@ pub struct StepContext<'a> { pub cc_access: Option<&'a dyn CcAccess>, pub speed_key: &'a str, pub chain_key: &'a str, - #[cfg(feature = "desktop")] pub mouse_x: f64, - #[cfg(feature = "desktop")] pub mouse_y: f64, - #[cfg(feature = "desktop")] pub mouse_down: f64, } diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index c73c0cb..e10d607 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -662,11 +662,8 @@ impl Forth { "speed" => Value::Float(ctx.speed, None), "stepdur" => Value::Float(ctx.step_duration(), None), "fill" => Value::Int(if ctx.fill { 1 } else { 0 }, None), - #[cfg(feature = "desktop")] "mx" => Value::Float(ctx.mouse_x, None), - #[cfg(feature = "desktop")] "my" => Value::Float(ctx.mouse_y, None), - #[cfg(feature = "desktop")] "mdown" => Value::Float(ctx.mouse_down, None), _ => Value::Int(0, None), }; diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 397108b..af50e0a 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -1,7 +1,7 @@ use crate::commands::AppCommand; use crate::engine::{LinkState, SequencerSnapshot}; use crate::services::{dict_nav, euclidean, help_nav, pattern_editor}; -use crate::state::{undo::UndoEntry, CyclicEnum, FlashKind, Modal, StagedPropChange}; +use crate::state::{undo::UndoEntry, FlashKind, Modal, StagedPropChange}; use super::App; @@ -344,8 +344,8 @@ impl App { // Audio settings (engine page) AppCommand::AudioSetSection(section) => self.audio.section = section, - AppCommand::AudioNextSection => self.audio.next_section(), - AppCommand::AudioPrevSection => self.audio.prev_section(), + AppCommand::AudioNextSection => self.audio.next_section(self.plugin_mode), + AppCommand::AudioPrevSection => self.audio.prev_section(self.plugin_mode), AppCommand::AudioOutputListUp => self.audio.output_list.move_up(), AppCommand::AudioOutputListDown(count) => self.audio.output_list.move_down(count), AppCommand::AudioOutputPageUp => self.audio.output_list.page_up(), @@ -353,8 +353,8 @@ impl App { AppCommand::AudioInputListUp => self.audio.input_list.move_up(), AppCommand::AudioInputListDown(count) => self.audio.input_list.move_down(count), AppCommand::AudioInputPageDown(count) => self.audio.input_list.page_down(count), - AppCommand::AudioSettingNext => self.audio.setting_kind = self.audio.setting_kind.next(), - AppCommand::AudioSettingPrev => self.audio.setting_kind = self.audio.setting_kind.prev(), + AppCommand::AudioSettingNext => self.audio.next_setting(self.plugin_mode), + AppCommand::AudioSettingPrev => self.audio.prev_setting(self.plugin_mode), AppCommand::SetOutputDevice(name) => self.audio.config.output_device = Some(name), AppCommand::SetInputDevice(name) => self.audio.config.input_device = Some(name), AppCommand::SetDeviceKind(kind) => self.audio.device_kind = kind, diff --git a/src/app/mod.rs b/src/app/mod.rs index b39db90..10495ff 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -69,6 +69,7 @@ pub struct App { pub options: OptionsState, pub panel: PanelState, pub midi: MidiState, + pub plugin_mode: bool, } impl Default for App { @@ -82,6 +83,15 @@ impl App { let variables = Arc::new(ArcSwap::from_pointee(HashMap::new())); let dict = Arc::new(Mutex::new(HashMap::new())); let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); + Self::build(variables, dict, rng) + } + + #[allow(dead_code)] + pub fn with_shared(variables: Variables, dict: Dictionary, rng: Rng) -> Self { + Self::build(variables, dict, rng) + } + + fn build(variables: Variables, dict: Dictionary, rng: Rng) -> Self { let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&dict), Arc::clone(&rng)); let live_keys = Arc::new(LiveKeyState::new()); @@ -113,6 +123,7 @@ impl App { options: OptionsState::default(), panel: PanelState::default(), midi: MidiState::new(), + plugin_mode: false, } } diff --git a/src/app/scripting.rs b/src/app/scripting.rs index fdb2aaa..8cdcd80 100644 --- a/src/app/scripting.rs +++ b/src/app/scripting.rs @@ -32,11 +32,8 @@ impl App { cc_access: None, speed_key: "", chain_key: "", - #[cfg(feature = "desktop")] mouse_x: 0.5, - #[cfg(feature = "desktop")] mouse_y: 0.5, - #[cfg(feature = "desktop")] mouse_down: 0.0, } } diff --git a/src/app/sequencer.rs b/src/app/sequencer.rs index c0088a3..bd7ce8b 100644 --- a/src/app/sequencer.rs +++ b/src/app/sequencer.rs @@ -34,8 +34,10 @@ impl App { }); } - pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender) { - for (bank, pattern) in self.project_state.take_dirty() { + pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender) -> bool { + let dirty = self.project_state.take_dirty(); + let had_dirty = !dirty.is_empty(); + for (bank, pattern) in dirty { let pat = self.project_state.project.pattern_at(bank, pattern); let snapshot = PatternSnapshot { speed: pat.speed, @@ -59,5 +61,6 @@ impl App { data: snapshot, }); } + had_dirty } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index f53ed47..6a25672 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -9,10 +9,14 @@ pub use timing::{substeps_in_window, StepTiming, SyncTime}; // AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs #[allow(unused_imports)] -pub use audio::{build_stream, AnalysisHandle, AudioStreamConfig, ScopeBuffer, SpectrumBuffer}; +pub use audio::{ + build_stream, spawn_analysis_thread, AnalysisHandle, AudioStreamConfig, ScopeBuffer, + SpectrumBuffer, +}; pub use link::LinkState; #[allow(unused_imports)] pub use sequencer::{ - spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand, - SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot, + parse_midi_command, spawn_sequencer, AudioCommand, MidiCommand, PatternChange, + PatternSnapshot, SeqCommand, SequencerConfig, SequencerHandle, SequencerSnapshot, + SequencerState, SharedSequencerState, StepSnapshot, TickInput, TickOutput, TimestampedCommand, }; diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index ba628e8..700c937 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -166,7 +166,26 @@ pub struct SequencerSnapshot { pub event_count: usize, } +impl From<&SharedSequencerState> for SequencerSnapshot { + fn from(s: &SharedSequencerState) -> Self { + Self { + active_patterns: s.active_patterns.clone(), + step_traces: Arc::clone(&s.step_traces), + event_count: s.event_count, + } + } +} + impl SequencerSnapshot { + #[allow(dead_code)] + pub fn empty() -> Self { + Self { + active_patterns: Vec::new(), + step_traces: Arc::new(HashMap::new()), + event_count: 0, + } + } + pub fn is_playing(&self, bank: usize, pattern: usize) -> bool { self.active_patterns .iter() @@ -452,7 +471,7 @@ impl RunsCounter { } } -pub(crate) struct TickInput { +pub struct TickInput { pub commands: Vec, pub playing: bool, pub beat: f64, @@ -463,11 +482,8 @@ pub(crate) struct TickInput { pub nudge_secs: f64, pub current_time_us: SyncTime, pub engine_time: f64, - #[cfg(feature = "desktop")] pub mouse_x: f64, - #[cfg(feature = "desktop")] pub mouse_y: f64, - #[cfg(feature = "desktop")] pub mouse_down: f64, } @@ -476,7 +492,7 @@ pub struct TimestampedCommand { pub time: Option, } -pub(crate) struct TickOutput { +pub struct TickOutput { pub audio_commands: Vec, pub new_tempo: Option, pub shared_state: SharedSequencerState, @@ -528,7 +544,7 @@ fn format_chain_key(buf: &mut String, bank: usize, pattern: usize) -> &str { buf } -pub(crate) struct SequencerState { +pub struct SequencerState { audio_state: AudioState, pattern_cache: PatternCache, pending_updates: HashMap<(usize, usize), PatternSnapshot>, @@ -710,11 +726,8 @@ impl SequencerState { input.nudge_secs, input.current_time_us, input.engine_time, - #[cfg(feature = "desktop")] input.mouse_x, - #[cfg(feature = "desktop")] input.mouse_y, - #[cfg(feature = "desktop")] input.mouse_down, ); @@ -842,9 +855,9 @@ impl SequencerState { nudge_secs: f64, _current_time_us: SyncTime, engine_time: f64, - #[cfg(feature = "desktop")] mouse_x: f64, - #[cfg(feature = "desktop")] mouse_y: f64, - #[cfg(feature = "desktop")] mouse_down: f64, + mouse_x: f64, + mouse_y: f64, + mouse_down: f64, ) -> StepResult { self.buf_audio_commands.clear(); self.buf_completed_iterations.clear(); @@ -924,11 +937,8 @@ impl SequencerState { cc_access: self.cc_access.as_deref(), speed_key, chain_key, - #[cfg(feature = "desktop")] mouse_x, - #[cfg(feature = "desktop")] mouse_y, - #[cfg(feature = "desktop")] mouse_down, }; if let Some(script) = resolved_script { @@ -1170,10 +1180,16 @@ fn sequencer_loop( engine_time, #[cfg(feature = "desktop")] mouse_x: f32::from_bits(mouse_x.load(Ordering::Relaxed)) as f64, + #[cfg(not(feature = "desktop"))] + mouse_x: 0.5, #[cfg(feature = "desktop")] mouse_y: f32::from_bits(mouse_y.load(Ordering::Relaxed)) as f64, + #[cfg(not(feature = "desktop"))] + mouse_y: 0.5, #[cfg(feature = "desktop")] mouse_down: f32::from_bits(mouse_down.load(Ordering::Relaxed)) as f64, + #[cfg(not(feature = "desktop"))] + mouse_down: 0.0, }; let output = seq_state.tick(input); @@ -1231,7 +1247,7 @@ fn sequencer_loop( } } -fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option, f64)> { +pub fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option, f64)> { if !cmd.starts_with("/midi/") { return None; } @@ -1384,11 +1400,8 @@ mod tests { nudge_secs: 0.0, current_time_us: 0, engine_time: 0.0, - #[cfg(feature = "desktop")] mouse_x: 0.5, - #[cfg(feature = "desktop")] mouse_y: 0.5, - #[cfg(feature = "desktop")] mouse_down: 0.0, } } @@ -1405,11 +1418,8 @@ mod tests { nudge_secs: 0.0, current_time_us: 0, engine_time: 0.0, - #[cfg(feature = "desktop")] mouse_x: 0.5, - #[cfg(feature = "desktop")] mouse_y: 0.5, - #[cfg(feature = "desktop")] mouse_down: 0.0, } } diff --git a/src/init.rs b/src/init.rs index 1a429e9..379c554 100644 --- a/src/init.rs +++ b/src/init.rs @@ -25,6 +25,7 @@ pub struct InitArgs { pub buffer: Option, } +#[allow(dead_code)] pub struct Init { pub app: App, pub link: Arc, diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index e290510..575d0fa 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -33,7 +33,7 @@ pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) { pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { - KeyCode::Char('q') => { + KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { action: ConfirmAction::Quit, selected: false, @@ -42,17 +42,17 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Tab => ctx.dispatch(AppCommand::AudioNextSection), KeyCode::BackTab => ctx.dispatch(AppCommand::AudioPrevSection), KeyCode::Up => match ctx.app.audio.section { - EngineSection::Devices => match ctx.app.audio.device_kind { + EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.device_kind { DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputListUp), DeviceKind::Input => ctx.dispatch(AppCommand::AudioInputListUp), }, EngineSection::Settings => { ctx.dispatch(AppCommand::AudioSettingPrev); } - EngineSection::Samples => {} + _ => {} }, KeyCode::Down => match ctx.app.audio.section { - EngineSection::Devices => match ctx.app.audio.device_kind { + EngineSection::Devices if !ctx.app.plugin_mode => match ctx.app.audio.device_kind { DeviceKind::Output => { let count = ctx.app.audio.output_devices.len(); ctx.dispatch(AppCommand::AudioOutputListDown(count)); @@ -65,10 +65,10 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input EngineSection::Settings => { ctx.dispatch(AppCommand::AudioSettingNext); } - EngineSection::Samples => {} + _ => {} }, KeyCode::PageUp => { - if ctx.app.audio.section == EngineSection::Devices { + if !ctx.app.plugin_mode && ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputPageUp), DeviceKind::Input => ctx.app.audio.input_list.page_up(), @@ -76,7 +76,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input } } KeyCode::PageDown => { - if ctx.app.audio.section == EngineSection::Devices { + if !ctx.app.plugin_mode && ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => { let count = ctx.app.audio.output_devices.len(); @@ -90,7 +90,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input } } KeyCode::Enter => { - if ctx.app.audio.section == EngineSection::Devices { + if !ctx.app.plugin_mode && ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => { let cursor = ctx.app.audio.output_list.cursor; @@ -112,20 +112,22 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input } } KeyCode::Left => match ctx.app.audio.section { - EngineSection::Devices => { + EngineSection::Devices if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output)); } EngineSection::Settings => cycle_engine_setting(ctx, false), - EngineSection::Samples => {} + _ => {} }, KeyCode::Right => match ctx.app.audio.section { - EngineSection::Devices => { + EngineSection::Devices if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input)); } EngineSection::Settings => cycle_engine_setting(ctx, true), - EngineSection::Samples => {} + _ => {} }, - KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart), + KeyCode::Char('R') if !ctx.app.plugin_mode => { + ctx.dispatch(AppCommand::AudioTriggerRestart); + } KeyCode::Char('A') => { use crate::state::file_browser::FileBrowserState; let state = FileBrowserState::new_load(String::new()); @@ -134,7 +136,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char('D') => { if ctx.app.audio.section == EngineSection::Samples { ctx.dispatch(AppCommand::RemoveLastSamplePath); - } else { + } else if !ctx.app.plugin_mode { ctx.dispatch(AppCommand::AudioRefreshDevices); let out_count = ctx.app.audio.output_devices.len(); let in_count = ctx.app.audio.input_devices.len(); @@ -144,15 +146,19 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input } } KeyCode::Char('h') => { - let _ = ctx.audio_tx.load().send(AudioCommand::Hush); + if !ctx.app.plugin_mode { + let _ = ctx.audio_tx.load().send(AudioCommand::Hush); + } let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); } KeyCode::Char('p') => { - let _ = ctx.audio_tx.load().send(AudioCommand::Panic); + if !ctx.app.plugin_mode { + let _ = ctx.audio_tx.load().send(AudioCommand::Panic); + } let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); } KeyCode::Char('r') => ctx.dispatch(AppCommand::ResetPeakVoices), - KeyCode::Char('t') => { + KeyCode::Char('t') if !ctx.app.plugin_mode => { let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate { cmd: "/sound/sine/dur/0.5/decay/0.2".into(), time: None, diff --git a/src/input/help_page.rs b/src/input/help_page.rs index 675abaa..27561af 100644 --- a/src/input/help_page.rs +++ b/src/input/help_page.rs @@ -66,7 +66,7 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe }, KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)), - KeyCode::Char('q') => { + KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { action: ConfirmAction::Quit, selected: false, @@ -236,7 +236,7 @@ pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe }, KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)), - KeyCode::Char('q') => { + KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { action: ConfirmAction::Quit, selected: false, diff --git a/src/input/main_page.rs b/src/input/main_page.rs index cebf5ad..47312c9 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -23,7 +23,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool ctx.app.panel.focus = PanelFocus::Side; } } - KeyCode::Char('q') => { + KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { action: ConfirmAction::Quit, selected: false, diff --git a/src/input/modal.rs b/src/input/modal.rs index 9d332b3..8560624 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -434,7 +434,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input } } Modal::KeybindingsHelp { scroll } => { - let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page).len(); + let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page, ctx.app.plugin_mode).len(); match key.code { KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal), KeyCode::Up | KeyCode::Char('k') => { diff --git a/src/input/options_page.rs b/src/input/options_page.rs index ce2dd6a..5fe867e 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -149,7 +149,7 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { - KeyCode::Char('q') => { + KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { action: ConfirmAction::Quit, selected: false, diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs index ab87e10..b10f98a 100644 --- a/src/input/patterns_page.rs +++ b/src/input/patterns_page.rs @@ -95,7 +95,7 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp ctx.app.send_mute_state(ctx.seq_cmd_tx); } } - KeyCode::Char('q') => { + KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { action: ConfirmAction::Quit, selected: false, diff --git a/src/services/stack_preview.rs b/src/services/stack_preview.rs index 79cc561..2f95e28 100644 --- a/src/services/stack_preview.rs +++ b/src/services/stack_preview.rs @@ -61,11 +61,8 @@ pub fn update_cache(editor_ctx: &EditorContext) { cc_access: None, speed_key: "", chain_key: "", - #[cfg(feature = "desktop")] mouse_x: 0.5, - #[cfg(feature = "desktop")] mouse_y: 0.5, - #[cfg(feature = "desktop")] mouse_down: 0.0, }; diff --git a/src/state/audio.rs b/src/state/audio.rs index 526a7bb..da0904c 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -258,12 +258,52 @@ impl AudioSettings { self.input_devices = doux::audio::list_input_devices(); } - pub fn next_section(&mut self) { - self.section = self.section.next(); + pub fn next_section(&mut self, plugin_mode: bool) { + self.section = if plugin_mode { + match self.section { + EngineSection::Settings => EngineSection::Samples, + EngineSection::Samples => EngineSection::Settings, + EngineSection::Devices => EngineSection::Settings, + } + } else { + self.section.next() + }; } - pub fn prev_section(&mut self) { - self.section = self.section.prev(); + pub fn prev_section(&mut self, plugin_mode: bool) { + self.section = if plugin_mode { + match self.section { + EngineSection::Settings => EngineSection::Samples, + EngineSection::Samples => EngineSection::Settings, + EngineSection::Devices => EngineSection::Settings, + } + } else { + self.section.prev() + }; + } + + pub fn next_setting(&mut self, plugin_mode: bool) { + self.setting_kind = if plugin_mode { + match self.setting_kind { + SettingKind::Polyphony => SettingKind::Nudge, + SettingKind::Nudge => SettingKind::Polyphony, + _ => SettingKind::Polyphony, + } + } else { + self.setting_kind.next() + }; + } + + pub fn prev_setting(&mut self, plugin_mode: bool) { + self.setting_kind = if plugin_mode { + match self.setting_kind { + SettingKind::Polyphony => SettingKind::Nudge, + SettingKind::Nudge => SettingKind::Polyphony, + _ => SettingKind::Polyphony, + } + } else { + self.setting_kind.prev() + }; } pub fn current_output_device_index(&self) -> usize { diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 17e436a..3749dcb 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -46,24 +46,31 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { }; // Calculate section heights - let devices_lines = devices_section_height(app) as usize; - let settings_lines: usize = 8; // header(1) + divider(1) + 6 rows + let plugin_mode = app.plugin_mode; + let devices_lines = if plugin_mode { + 0 + } else { + devices_section_height(app) as usize + }; + let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1) - let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines; + + let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with + let total_lines = devices_lines + settings_lines + samples_lines + sections_gap; let max_visible = padded.height as usize; // Calculate scroll offset based on focused section + let settings_start = if plugin_mode { 0 } else { devices_lines + 1 }; let (focus_start, focus_height) = match app.audio.section { EngineSection::Devices => (0, devices_lines), - EngineSection::Settings => (devices_lines + 1, settings_lines), - EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines), + EngineSection::Settings => (settings_start, settings_lines), + EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines), }; let scroll_offset = if total_lines <= max_visible { 0 } else { - // Keep focused section in view (top-aligned when possible) let focus_end = focus_start + focus_height; if focus_end <= max_visible { 0 @@ -75,25 +82,26 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { let viewport_top = padded.y as i32; let viewport_bottom = (padded.y + padded.height) as i32; - // Render each section at adjusted position let mut y = viewport_top - scroll_offset as i32; - // Devices section - let devices_top = y; - let devices_bottom = y + devices_lines as i32; - if devices_bottom > viewport_top && devices_top < viewport_bottom { - let clipped_y = devices_top.max(viewport_top) as u16; - let clipped_height = - (devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16; - let devices_area = Rect { - x: padded.x, - y: clipped_y, - width: padded.width, - height: clipped_height, - }; - render_devices(frame, app, devices_area); + // Devices section (skip in plugin mode) + if !plugin_mode { + let devices_top = y; + let devices_bottom = y + devices_lines as i32; + if devices_bottom > viewport_top && devices_top < viewport_bottom { + let clipped_y = devices_top.max(viewport_top) as u16; + let clipped_height = + (devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16; + let devices_area = Rect { + x: padded.x, + y: clipped_y, + width: padded.width, + height: clipped_height, + }; + render_devices(frame, app, devices_area); + } + y += devices_lines as i32 + 1; } - y += devices_lines as i32 + 1; // +1 for blank line // Settings section let settings_top = y; @@ -310,8 +318,6 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { let label_style = Style::new().fg(theme.engine.label); let value_style = Style::new().fg(theme.engine.value); - let channels_focused = section_focused && app.audio.setting_kind == SettingKind::Channels; - let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize; let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony; let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge; let nudge_ms = app.metrics.nudge_ms; @@ -321,8 +327,15 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { format!("{nudge_ms:+.1} ms") }; - let rows = vec![ - Row::new(vec![ + let mut rows = Vec::new(); + + if !app.plugin_mode { + let channels_focused = + section_focused && app.audio.setting_kind == SettingKind::Channels; + let buffer_focused = + section_focused && app.audio.setting_kind == SettingKind::BufferSize; + + rows.push(Row::new(vec![ Span::styled( if channels_focused { "> Channels" @@ -337,8 +350,8 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { highlight, normal, ), - ]), - Row::new(vec![ + ])); + rows.push(Row::new(vec![ Span::styled( if buffer_focused { "> Buffer" @@ -357,38 +370,42 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { highlight, normal, ), - ]), - Row::new(vec![ - Span::styled( - if polyphony_focused { - "> Voices" - } else { - " Voices" - }, - label_style, - ), - render_selector( - &format!("{}", app.audio.config.max_voices), - polyphony_focused, - highlight, - normal, - ), - ]), - Row::new(vec![ - Span::styled( - if nudge_focused { "> Nudge" } else { " Nudge" }, - label_style, - ), - render_selector(&nudge_label, nudge_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled(" Sample rate", label_style), - Span::styled( - format!("{:.0} Hz", app.audio.config.sample_rate), - value_style, - ), - ]), - Row::new(vec![ + ])); + } + + rows.push(Row::new(vec![ + Span::styled( + if polyphony_focused { + "> Voices" + } else { + " Voices" + }, + label_style, + ), + render_selector( + &format!("{}", app.audio.config.max_voices), + polyphony_focused, + highlight, + normal, + ), + ])); + rows.push(Row::new(vec![ + Span::styled( + if nudge_focused { "> Nudge" } else { " Nudge" }, + label_style, + ), + render_selector(&nudge_label, nudge_focused, highlight, normal), + ])); + rows.push(Row::new(vec![ + Span::styled(" Sample rate", label_style), + Span::styled( + format!("{:.0} Hz", app.audio.config.sample_rate), + value_style, + ), + ])); + + if !app.plugin_mode { + rows.push(Row::new(vec![ Span::styled(" Audio host", label_style), Span::styled( if app.audio.config.host_name.is_empty() { @@ -398,8 +415,8 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { }, value_style, ), - ]), - ]; + ])); + } let table = Table::new(rows, [Constraint::Length(14), Constraint::Fill(1)]); frame.render_widget(table, content_area); diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index ab36d74..51e4f85 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -1,14 +1,18 @@ use crate::page::Page; -pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str)> { +pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> { let mut bindings = vec![ ("F1–F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"), ("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"), - ("q", "Quit", "Quit application"), + ]; + if !plugin_mode { + bindings.push(("q", "Quit", "Quit application")); + } + bindings.extend([ ("s", "Save", "Save project"), ("l", "Load", "Load project"), ("?", "Keybindings", "Show this help"), - ]; + ]); // Page-specific bindings match page { diff --git a/src/views/render.rs b/src/views/render.rs index d515495..fb6680b 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -78,7 +78,7 @@ pub fn render( frame.render_widget(Block::new().style(Style::default().bg(bg_color)), term); if app.ui.show_title { - title_view::render(frame, term, &app.ui); + title_view::render(frame, term, &app.ui, app.plugin_mode); let mut fx = app.ui.title_fx.borrow_mut(); if let Some(effect) = fx.as_mut() { @@ -1036,7 +1036,7 @@ fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: R .border_color(theme.modal.editor) .render_centered(frame, term); - let bindings = super::keybindings::bindings_for(app.page); + let bindings = super::keybindings::bindings_for(app.page, app.plugin_mode); let visible_rows = inner.height.saturating_sub(2) as usize; let rows: Vec = bindings diff --git a/src/views/title_view.rs b/src/views/title_view.rs index 7a2786c..a93c8c4 100644 --- a/src/views/title_view.rs +++ b/src/views/title_view.rs @@ -8,7 +8,7 @@ use tui_big_text::{BigText, PixelSize}; use crate::state::ui::UiState; use crate::theme; -pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { +pub fn render(frame: &mut Frame, area: Rect, ui: &UiState, plugin_mode: bool) { let theme = theme::get(); frame.render_widget(&ui.sparkles, area); @@ -39,15 +39,17 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { Line::from(Span::styled("AGPL-3.0", license_style)), ]; - let keybindings = [ + let mut keybindings = vec![ ("Ctrl+Arrows", "Navigate Views"), ("Enter", "Edit Step"), ("Space", "Play/Stop"), ("s", "Save"), ("l", "Load"), - ("q", "Quit"), - ("?", "Keybindings"), ]; + if !plugin_mode { + keybindings.push(("q", "Quit")); + } + keybindings.push(("?", "Keybindings")); let key_style = Style::new().fg(theme.modal.confirm); let desc_style = Style::new().fg(theme.ui.text_primary); diff --git a/tests/forth/harness.rs b/tests/forth/harness.rs index 209f834..43bb1bf 100644 --- a/tests/forth/harness.rs +++ b/tests/forth/harness.rs @@ -23,6 +23,9 @@ pub fn default_ctx() -> StepContext<'static> { cc_access: None, speed_key: "__speed_0_0__", chain_key: "__chain_0_0__", + mouse_x: 0.5, + mouse_y: 0.5, + mouse_down: 0.0, } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..4b9d05e --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2021" + +[dependencies] +nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug" } diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..11fecb6 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,3 @@ +fn main() -> nih_plug_xtask::Result<()> { + nih_plug_xtask::main() +}