diff --git a/.cargo/baseview b/.cargo/baseview deleted file mode 160000 index 237d323..0000000 --- a/.cargo/baseview +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 237d323c729f3aa99476ba3efa50129c5e86cad3 diff --git a/.cargo/egui-baseview b/.cargo/egui-baseview deleted file mode 160000 index c5ad2f4..0000000 --- a/.cargo/egui-baseview +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c5ad2f4bd094b100321a804122cf2218b43370da diff --git a/Cargo.toml b/Cargo.toml index c200177..057fe37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "crates/plugins", "crates/baseview", "crates/egui-baseview", "xtask"] +members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"] [workspace.package] version = "0.0.9" @@ -92,11 +92,14 @@ codegen-units = 1 panic = "abort" strip = true +[patch."https://github.com/robbert-vdh/nih-plug"] +nih_plug_egui = { path = "plugins/nih-plug-egui" } + [patch."https://github.com/BillyDM/egui-baseview.git"] -egui-baseview = { path = "crates/egui-baseview" } +egui-baseview = { path = "plugins/egui-baseview" } [patch."https://github.com/RustAudio/baseview.git"] -baseview = { path = "crates/baseview" } +baseview = { path = "plugins/baseview" } [package.metadata.bundle.bin.cagire-desktop] name = "Cagire" diff --git a/crates/baseview/Cargo.toml b/plugins/baseview/Cargo.toml similarity index 100% rename from crates/baseview/Cargo.toml rename to plugins/baseview/Cargo.toml diff --git a/crates/baseview/LICENSE-APACHE b/plugins/baseview/LICENSE-APACHE similarity index 100% rename from crates/baseview/LICENSE-APACHE rename to plugins/baseview/LICENSE-APACHE diff --git a/crates/baseview/LICENSE-MIT b/plugins/baseview/LICENSE-MIT similarity index 100% rename from crates/baseview/LICENSE-MIT rename to plugins/baseview/LICENSE-MIT diff --git a/crates/baseview/src/clipboard.rs b/plugins/baseview/src/clipboard.rs similarity index 100% rename from crates/baseview/src/clipboard.rs rename to plugins/baseview/src/clipboard.rs diff --git a/crates/baseview/src/event.rs b/plugins/baseview/src/event.rs similarity index 91% rename from crates/baseview/src/event.rs rename to plugins/baseview/src/event.rs index 64431a0..cfb48ee 100644 --- a/crates/baseview/src/event.rs +++ b/plugins/baseview/src/event.rs @@ -40,6 +40,8 @@ pub enum MouseEvent { CursorMoved { /// The logical coordinates of the mouse position position: Point, + /// The screen-absolute logical coordinates of the mouse position + screen_position: Point, /// The modifiers that were held down just before the event. modifiers: Modifiers, }, @@ -81,6 +83,8 @@ pub enum MouseEvent { DragEntered { /// The logical coordinates of the mouse position position: Point, + /// The screen-absolute logical coordinates of the mouse position + screen_position: Point, /// The modifiers that were held down just before the event. modifiers: Modifiers, /// Data being dragged @@ -90,6 +94,8 @@ pub enum MouseEvent { DragMoved { /// The logical coordinates of the mouse position position: Point, + /// The screen-absolute logical coordinates of the mouse position + screen_position: Point, /// The modifiers that were held down just before the event. modifiers: Modifiers, /// Data being dragged @@ -101,6 +107,8 @@ pub enum MouseEvent { DragDropped { /// The logical coordinates of the mouse position position: Point, + /// The screen-absolute logical coordinates of the mouse position + screen_position: Point, /// The modifiers that were held down just before the event. modifiers: Modifiers, /// Data being dragged diff --git a/crates/baseview/src/gl/macos.rs b/plugins/baseview/src/gl/macos.rs similarity index 98% rename from crates/baseview/src/gl/macos.rs rename to plugins/baseview/src/gl/macos.rs index f11fb1f..f9954a4 100644 --- a/crates/baseview/src/gl/macos.rs +++ b/plugins/baseview/src/gl/macos.rs @@ -139,6 +139,7 @@ impl GlContext { pub(crate) fn resize(&self, size: NSSize) { unsafe { NSView::setFrameSize(self.view, size) }; unsafe { + let _: () = msg_send![self.context, update]; let _: () = msg_send![self.view, setNeedsDisplay: YES]; } } diff --git a/crates/baseview/src/gl/mod.rs b/plugins/baseview/src/gl/mod.rs similarity index 100% rename from crates/baseview/src/gl/mod.rs rename to plugins/baseview/src/gl/mod.rs diff --git a/crates/baseview/src/gl/win.rs b/plugins/baseview/src/gl/win.rs similarity index 100% rename from crates/baseview/src/gl/win.rs rename to plugins/baseview/src/gl/win.rs diff --git a/crates/baseview/src/gl/x11.rs b/plugins/baseview/src/gl/x11.rs similarity index 100% rename from crates/baseview/src/gl/x11.rs rename to plugins/baseview/src/gl/x11.rs diff --git a/crates/baseview/src/gl/x11/errors.rs b/plugins/baseview/src/gl/x11/errors.rs similarity index 100% rename from crates/baseview/src/gl/x11/errors.rs rename to plugins/baseview/src/gl/x11/errors.rs diff --git a/crates/baseview/src/keyboard.rs b/plugins/baseview/src/keyboard.rs similarity index 100% rename from crates/baseview/src/keyboard.rs rename to plugins/baseview/src/keyboard.rs diff --git a/crates/baseview/src/lib.rs b/plugins/baseview/src/lib.rs similarity index 100% rename from crates/baseview/src/lib.rs rename to plugins/baseview/src/lib.rs diff --git a/crates/baseview/src/macos/keyboard.rs b/plugins/baseview/src/macos/keyboard.rs similarity index 100% rename from crates/baseview/src/macos/keyboard.rs rename to plugins/baseview/src/macos/keyboard.rs diff --git a/crates/baseview/src/macos/mod.rs b/plugins/baseview/src/macos/mod.rs similarity index 100% rename from crates/baseview/src/macos/mod.rs rename to plugins/baseview/src/macos/mod.rs diff --git a/crates/baseview/src/macos/view.rs b/plugins/baseview/src/macos/view.rs similarity index 89% rename from crates/baseview/src/macos/view.rs rename to plugins/baseview/src/macos/view.rs index dd578c8..393b00c 100644 --- a/crates/baseview/src/macos/view.rs +++ b/plugins/baseview/src/macos/view.rs @@ -189,6 +189,11 @@ unsafe fn create_view_class() -> &'static Class { view_did_change_backing_properties as extern "C" fn(&Object, Sel, id), ); + class.add_method( + sel!(setFrameSize:), + set_frame_size as extern "C" fn(&Object, Sel, NSSize), + ); + class.add_method( sel!(draggingEntered:), dragging_entered as extern "C" fn(&Object, Sel, id) -> NSUInteger, @@ -390,6 +395,54 @@ extern "C" fn update_tracking_areas(this: &Object, _self: Sel, _: id) { } } +extern "C" fn set_frame_size(this: &Object, _: Sel, new_size: NSSize) { + unsafe { + let superclass = msg_send![this, superclass]; + let () = msg_send![super(this, superclass), setFrameSize: new_size]; + } + + let state_ptr: *const c_void = unsafe { *this.get_ivar(BASEVIEW_STATE_IVAR) }; + if state_ptr.is_null() { + return; + } + + let state = unsafe { WindowState::from_view(this) }; + + let scale_factor = unsafe { + let ns_window: *mut Object = msg_send![this, window]; + if ns_window.is_null() { 1.0 } else { NSWindow::backingScaleFactor(ns_window) } + }; + + let new_window_info = WindowInfo::from_logical_size( + Size::new(new_size.width, new_size.height), + scale_factor, + ); + + let old_info = state.window_info.get(); + if new_window_info.physical_size() != old_info.physical_size() { + state.window_info.set(new_window_info); + + #[cfg(feature = "opengl")] + if let Some(gl_context) = &state.window_inner.gl_context { + gl_context.resize(new_size); + } + + state.trigger_deferrable_event(Event::Window(WindowEvent::Resized(new_window_info))); + } +} + +fn get_screen_position() -> Point { + unsafe { + let screen_point: NSPoint = msg_send![class!(NSEvent), mouseLocation]; + let main_screen: id = msg_send![class!(NSScreen), mainScreen]; + let screen_frame: NSRect = msg_send![main_screen, frame]; + Point { + x: screen_point.x, + y: screen_frame.size.height - screen_point.y, + } + } +} + extern "C" fn mouse_moved(this: &Object, _sel: Sel, event: id) { let state = unsafe { WindowState::from_view(this) }; @@ -401,9 +454,11 @@ extern "C" fn mouse_moved(this: &Object, _sel: Sel, event: id) { let modifiers = unsafe { NSEvent::modifierFlags(event) }; let position = Point { x: point.x, y: point.y }; + let screen_position = get_screen_position(); state.trigger_event(Event::Mouse(MouseEvent::CursorMoved { position, + screen_position, modifiers: make_modifiers(modifiers), })); } @@ -430,9 +485,9 @@ extern "C" fn scroll_wheel(this: &Object, _: Sel, event: id) { })); } -fn get_drag_position(sender: id) -> Point { +fn get_drag_position(sender: id) -> (Point, Point) { let point: NSPoint = unsafe { msg_send![sender, draggingLocation] }; - Point::new(point.x, point.y) + (Point::new(point.x, point.y), get_screen_position()) } fn get_drop_data(sender: id) -> DropData { @@ -473,9 +528,11 @@ extern "C" fn dragging_entered(this: &Object, _sel: Sel, sender: id) -> NSUInteg let state = unsafe { WindowState::from_view(this) }; let modifiers = state.keyboard_state().last_mods(); let drop_data = get_drop_data(sender); + let (position, screen_position) = get_drag_position(sender); let event = MouseEvent::DragEntered { - position: get_drag_position(sender), + position, + screen_position, modifiers: make_modifiers(modifiers), data: drop_data, }; @@ -487,9 +544,11 @@ extern "C" fn dragging_updated(this: &Object, _sel: Sel, sender: id) -> NSUInteg let state = unsafe { WindowState::from_view(this) }; let modifiers = state.keyboard_state().last_mods(); let drop_data = get_drop_data(sender); + let (position, screen_position) = get_drag_position(sender); let event = MouseEvent::DragMoved { - position: get_drag_position(sender), + position, + screen_position, modifiers: make_modifiers(modifiers), data: drop_data, }; @@ -508,9 +567,11 @@ extern "C" fn perform_drag_operation(this: &Object, _sel: Sel, sender: id) -> BO let state = unsafe { WindowState::from_view(this) }; let modifiers = state.keyboard_state().last_mods(); let drop_data = get_drop_data(sender); + let (position, screen_position) = get_drag_position(sender); let event = MouseEvent::DragDropped { - position: get_drag_position(sender), + position, + screen_position, modifiers: make_modifiers(modifiers), data: drop_data, }; diff --git a/crates/baseview/src/macos/window.rs b/plugins/baseview/src/macos/window.rs similarity index 95% rename from crates/baseview/src/macos/window.rs rename to plugins/baseview/src/macos/window.rs index 57bca10..6a2a35b 100644 --- a/crates/baseview/src/macos/window.rs +++ b/plugins/baseview/src/macos/window.rs @@ -65,7 +65,7 @@ pub(super) struct WindowInner { ns_view: id, #[cfg(feature = "opengl")] - gl_context: Option, + pub(super) gl_context: Option, } impl WindowInner { @@ -311,29 +311,32 @@ impl<'a> Window<'a> { 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 physical_size(&self) -> crate::PhySize { + unsafe { + let frame: NSRect = NSView::frame(self.inner.ns_view); + let ns_window: *mut Object = msg_send![self.inner.ns_view, window]; + let scale: f64 = if ns_window.is_null() { + 1.0 + } else { + NSWindow::backingScaleFactor(ns_window) + }; + crate::PhySize { + width: (frame.size.width * scale).round() as u32, + height: (frame.size.height * scale).round() as u32, + } + } + } + pub fn set_mouse_cursor(&mut self, _mouse_cursor: MouseCursor) { todo!() } diff --git a/crates/baseview/src/mouse_cursor.rs b/plugins/baseview/src/mouse_cursor.rs similarity index 100% rename from crates/baseview/src/mouse_cursor.rs rename to plugins/baseview/src/mouse_cursor.rs diff --git a/crates/baseview/src/win/cursor.rs b/plugins/baseview/src/win/cursor.rs similarity index 100% rename from crates/baseview/src/win/cursor.rs rename to plugins/baseview/src/win/cursor.rs diff --git a/crates/baseview/src/win/drop_target.rs b/plugins/baseview/src/win/drop_target.rs similarity index 96% rename from crates/baseview/src/win/drop_target.rs rename to plugins/baseview/src/win/drop_target.rs index b9ba580..e530a43 100644 --- a/crates/baseview/src/win/drop_target.rs +++ b/plugins/baseview/src/win/drop_target.rs @@ -73,6 +73,7 @@ pub(super) struct DropTarget { // 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, + drag_screen_position: Point, drop_data: DropData, } @@ -84,6 +85,7 @@ impl DropTarget { window_state, drag_position: Point::new(0.0, 0.0), + drag_screen_position: Point::new(0.0, 0.0), drop_data: DropData::None, } } @@ -117,6 +119,7 @@ impl DropTarget { let Some(window_state) = self.window_state.upgrade() else { return; }; + self.drag_screen_position = Point::new(pt.x as f64, pt.y as f64); 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); @@ -216,6 +219,7 @@ impl DropTarget { let event = MouseEvent::DragEntered { position: drop_target.drag_position, + screen_position: drop_target.drag_screen_position, modifiers, data: drop_target.drop_data.clone(), }; @@ -240,6 +244,7 @@ impl DropTarget { let event = MouseEvent::DragMoved { position: drop_target.drag_position, + screen_position: drop_target.drag_screen_position, modifiers, data: drop_target.drop_data.clone(), }; @@ -272,6 +277,7 @@ impl DropTarget { let event = MouseEvent::DragDropped { position: drop_target.drag_position, + screen_position: drop_target.drag_screen_position, modifiers, data: drop_target.drop_data.clone(), }; diff --git a/crates/baseview/src/win/hook.rs b/plugins/baseview/src/win/hook.rs similarity index 100% rename from crates/baseview/src/win/hook.rs rename to plugins/baseview/src/win/hook.rs diff --git a/crates/baseview/src/win/keyboard.rs b/plugins/baseview/src/win/keyboard.rs similarity index 100% rename from crates/baseview/src/win/keyboard.rs rename to plugins/baseview/src/win/keyboard.rs diff --git a/crates/baseview/src/win/mod.rs b/plugins/baseview/src/win/mod.rs similarity index 100% rename from crates/baseview/src/win/mod.rs rename to plugins/baseview/src/win/mod.rs diff --git a/crates/baseview/src/win/window.rs b/plugins/baseview/src/win/window.rs similarity index 97% rename from crates/baseview/src/win/window.rs rename to plugins/baseview/src/win/window.rs index 51619d0..fce33ae 100644 --- a/crates/baseview/src/win/window.rs +++ b/plugins/baseview/src/win/window.rs @@ -1,14 +1,15 @@ use winapi::shared::guiddef::GUID; use winapi::shared::minwindef::{ATOM, FALSE, LOWORD, LPARAM, LRESULT, UINT, WPARAM}; -use winapi::shared::windef::{HWND, RECT}; +use winapi::shared::windef::{HWND, POINT, 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, + GetCursorPos, 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, @@ -35,8 +36,8 @@ 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, + Event, MouseButton, MouseCursor, MouseEvent, PhyPoint, PhySize, Point, ScrollDelta, Size, + WindowEvent, WindowHandler, WindowInfo, WindowOpenOptions, WindowScalePolicy, }; use super::cursor::cursor_to_lpcwstr; @@ -202,8 +203,14 @@ unsafe fn wnd_proc_inner( let physical_pos = PhyPoint { x, y }; let logical_pos = physical_pos.to_logical(&window_state.window_info.borrow()); + + let mut screen_pt = POINT { x: 0, y: 0 }; + GetCursorPos(&mut screen_pt); + let screen_position = Point::new(screen_pt.x as f64, screen_pt.y as f64); + let move_event = Event::Mouse(MouseEvent::CursorMoved { position: logical_pos, + screen_position, modifiers: window_state .keyboard_state .borrow() @@ -822,6 +829,10 @@ impl Window<'_> { self.state.deferred_tasks.borrow_mut().push_back(task); } + pub fn physical_size(&self) -> PhySize { + self.state.window_info().physical_size() + } + pub fn set_mouse_cursor(&mut self, mouse_cursor: MouseCursor) { self.state.cursor_icon.set(mouse_cursor); unsafe { diff --git a/crates/baseview/src/window.rs b/plugins/baseview/src/window.rs similarity index 94% rename from crates/baseview/src/window.rs rename to plugins/baseview/src/window.rs index 25b53d1..49d81aa 100644 --- a/crates/baseview/src/window.rs +++ b/plugins/baseview/src/window.rs @@ -6,7 +6,7 @@ use raw_window_handle::{ use crate::event::{Event, EventStatus}; use crate::window_open_options::WindowOpenOptions; -use crate::{MouseCursor, Size}; +use crate::{MouseCursor, PhySize, Size}; #[cfg(target_os = "macos")] use crate::macos as platform; @@ -92,6 +92,11 @@ impl<'a> Window<'a> { self.window.close(); } + /// Returns the current physical size of the window by querying the OS directly. + pub fn physical_size(&self) -> PhySize { + self.window.physical_size() + } + /// 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) { diff --git a/crates/baseview/src/window_info.rs b/plugins/baseview/src/window_info.rs similarity index 100% rename from crates/baseview/src/window_info.rs rename to plugins/baseview/src/window_info.rs diff --git a/crates/baseview/src/window_open_options.rs b/plugins/baseview/src/window_open_options.rs similarity index 100% rename from crates/baseview/src/window_open_options.rs rename to plugins/baseview/src/window_open_options.rs diff --git a/crates/baseview/src/x11/cursor.rs b/plugins/baseview/src/x11/cursor.rs similarity index 100% rename from crates/baseview/src/x11/cursor.rs rename to plugins/baseview/src/x11/cursor.rs diff --git a/crates/baseview/src/x11/event_loop.rs b/plugins/baseview/src/x11/event_loop.rs similarity index 96% rename from crates/baseview/src/x11/event_loop.rs rename to plugins/baseview/src/x11/event_loop.rs index 53375d6..00ef1de 100644 --- a/crates/baseview/src/x11/event_loop.rs +++ b/plugins/baseview/src/x11/event_loop.rs @@ -175,11 +175,14 @@ impl EventLoop { 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); + let screen_physical = PhyPoint::new(event.root_x as i32, event.root_y as i32); + let screen_position = screen_physical.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, + screen_position, modifiers: key_mods(event.state), }), ); @@ -194,10 +197,13 @@ impl EventLoop { // 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); + let screen_physical = PhyPoint::new(event.root_x as i32, event.root_y as i32); + let screen_position = screen_physical.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, + screen_position, modifiers: key_mods(event.state), }), ); diff --git a/crates/baseview/src/x11/keyboard.rs b/plugins/baseview/src/x11/keyboard.rs similarity index 100% rename from crates/baseview/src/x11/keyboard.rs rename to plugins/baseview/src/x11/keyboard.rs diff --git a/crates/baseview/src/x11/mod.rs b/plugins/baseview/src/x11/mod.rs similarity index 100% rename from crates/baseview/src/x11/mod.rs rename to plugins/baseview/src/x11/mod.rs diff --git a/crates/baseview/src/x11/visual_info.rs b/plugins/baseview/src/x11/visual_info.rs similarity index 100% rename from crates/baseview/src/x11/visual_info.rs rename to plugins/baseview/src/x11/visual_info.rs diff --git a/crates/baseview/src/x11/window.rs b/plugins/baseview/src/x11/window.rs similarity index 99% rename from crates/baseview/src/x11/window.rs rename to plugins/baseview/src/x11/window.rs index e556fe2..7daf862 100644 --- a/crates/baseview/src/x11/window.rs +++ b/plugins/baseview/src/x11/window.rs @@ -296,6 +296,10 @@ impl<'a> Window<'a> { Ok(()) } + pub fn physical_size(&self) -> crate::PhySize { + self.inner.window_info.physical_size() + } + pub fn set_mouse_cursor(&self, mouse_cursor: MouseCursor) { if self.inner.mouse_cursor.get() == mouse_cursor { return; diff --git a/crates/baseview/src/x11/xcb_connection.rs b/plugins/baseview/src/x11/xcb_connection.rs similarity index 100% rename from crates/baseview/src/x11/xcb_connection.rs rename to plugins/baseview/src/x11/xcb_connection.rs diff --git a/crates/plugins/Cargo.toml b/plugins/cagire-plugins/Cargo.toml similarity index 84% rename from crates/plugins/Cargo.toml rename to plugins/cagire-plugins/Cargo.toml index a4392c0..fd1649c 100644 --- a/crates/plugins/Cargo.toml +++ b/plugins/cagire-plugins/Cargo.toml @@ -11,9 +11,9 @@ crate-type = ["cdylib", "lib"] [dependencies] cagire = { path = "../.." } -cagire-forth = { path = "../forth" } -cagire-project = { path = "../project" } -cagire-ratatui = { path = "../ratatui" } +cagire-forth = { path = "../../crates/forth" } +cagire-project = { path = "../../crates/project" } +cagire-ratatui = { path = "../../crates/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" } diff --git a/crates/plugins/src/editor.rs b/plugins/cagire-plugins/src/editor.rs similarity index 82% rename from crates/plugins/src/editor.rs rename to plugins/cagire-plugins/src/editor.rs index c4e8cbb..7b19441 100644 --- a/crates/plugins/src/editor.rs +++ b/plugins/cagire-plugins/src/editor.rs @@ -41,6 +41,28 @@ enum FontChoice { } impl FontChoice { + fn from_setting(s: &str) -> Self { + match s { + "6x13" => Self::Size6x13, + "7x13" => Self::Size7x13, + "9x15" => Self::Size9x15, + "9x18" => Self::Size9x18, + "10x20" => Self::Size10x20, + _ => Self::Size8x13, + } + } + + fn to_setting(self) -> &'static str { + match self { + Self::Size6x13 => "6x13", + Self::Size7x13 => "7x13", + Self::Size8x13 => "8x13", + Self::Size9x15 => "9x15", + Self::Size9x18 => "9x18", + Self::Size10x20 => "10x20", + } + } + fn label(self) -> &'static str { match self { Self::Size6x13 => "6x13 (Compact)", @@ -117,6 +139,7 @@ pub fn create_editor( dict: Dictionary, rng: Rng, ) -> Option> { + let egui_state_clone = Arc::clone(&egui_state); create_egui_editor( egui_state, None::, @@ -125,6 +148,9 @@ pub fn create_editor( panel_fill: egui::Color32::BLACK, ..egui::Visuals::dark() }); + ctx.style_mut(|style| { + style.spacing.window_margin = egui::Margin::ZERO; + }); }, move |ctx, _setter, state| { let editor = state.get_or_insert_with(|| { @@ -144,6 +170,11 @@ pub fn create_editor( app.project_state.project = params.project.lock().clone(); app.mark_all_patterns_dirty(); + // Init window size from egui state + let (w, h) = egui_state_clone.size(); + app.ui.window_width = w; + app.ui.window_height = h; + EditorState { app, terminal: create_terminal(FontChoice::Size8x13), @@ -221,6 +252,23 @@ pub fn create_editor( cagire::state::effects::tick_effects(&mut editor.app.ui, editor.app.page); + // Sync font/zoom from App state (options page or context menu may have changed them) + let desired_font = FontChoice::from_setting(&editor.app.ui.font); + if desired_font != editor.current_font { + editor.terminal = create_terminal(desired_font); + editor.current_font = desired_font; + } + if (editor.app.ui.zoom_factor - editor.zoom_factor).abs() > 0.01 { + editor.zoom_factor = editor.app.ui.zoom_factor; + ctx.set_zoom_factor(editor.zoom_factor); + } + + // Sync window size: if app state differs from egui state, request resize + let (cur_w, cur_h) = egui_state_clone.size(); + if editor.app.ui.window_width != cur_w || editor.app.ui.window_height != cur_h { + egui_state_clone.set_requested_size((editor.app.ui.window_width, editor.app.ui.window_height)); + } + let elapsed = editor.last_frame.elapsed(); editor.last_frame = Instant::now(); @@ -236,7 +284,7 @@ pub fn create_editor( let current_zoom = editor.zoom_factor; egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .frame(egui::Frame::NONE) .show(ctx, |ui| { ui.add(editor.terminal.backend_mut()); @@ -249,6 +297,7 @@ pub fn create_editor( ui.menu_button("Font", |ui| { for choice in FontChoice::ALL { if ui.selectable_label(current_font == choice, choice.label()).clicked() { + editor.app.ui.font = choice.to_setting().to_string(); editor.terminal = create_terminal(choice); editor.current_font = choice; ui.close(); @@ -260,6 +309,7 @@ pub fn create_editor( let selected = (current_zoom - level).abs() < 0.01; let label = format!("{:.0}%", level * 100.0); if ui.selectable_label(selected, label).clicked() { + editor.app.ui.zoom_factor = level; editor.zoom_factor = level; ctx.set_zoom_factor(level); ui.close(); diff --git a/crates/plugins/src/input_egui.rs b/plugins/cagire-plugins/src/input_egui.rs similarity index 100% rename from crates/plugins/src/input_egui.rs rename to plugins/cagire-plugins/src/input_egui.rs diff --git a/crates/plugins/src/lib.rs b/plugins/cagire-plugins/src/lib.rs similarity index 100% rename from crates/plugins/src/lib.rs rename to plugins/cagire-plugins/src/lib.rs diff --git a/crates/plugins/src/main.rs b/plugins/cagire-plugins/src/main.rs similarity index 100% rename from crates/plugins/src/main.rs rename to plugins/cagire-plugins/src/main.rs diff --git a/crates/plugins/src/params.rs b/plugins/cagire-plugins/src/params.rs similarity index 100% rename from crates/plugins/src/params.rs rename to plugins/cagire-plugins/src/params.rs diff --git a/crates/egui-baseview/Cargo.toml b/plugins/egui-baseview/Cargo.toml similarity index 100% rename from crates/egui-baseview/Cargo.toml rename to plugins/egui-baseview/Cargo.toml diff --git a/crates/egui-baseview/LICENSE b/plugins/egui-baseview/LICENSE similarity index 100% rename from crates/egui-baseview/LICENSE rename to plugins/egui-baseview/LICENSE diff --git a/crates/egui-baseview/src/lib.rs b/plugins/egui-baseview/src/lib.rs similarity index 100% rename from crates/egui-baseview/src/lib.rs rename to plugins/egui-baseview/src/lib.rs diff --git a/crates/egui-baseview/src/renderer.rs b/plugins/egui-baseview/src/renderer.rs similarity index 100% rename from crates/egui-baseview/src/renderer.rs rename to plugins/egui-baseview/src/renderer.rs diff --git a/crates/egui-baseview/src/renderer/opengl.rs b/plugins/egui-baseview/src/renderer/opengl.rs similarity index 100% rename from crates/egui-baseview/src/renderer/opengl.rs rename to plugins/egui-baseview/src/renderer/opengl.rs diff --git a/crates/egui-baseview/src/renderer/opengl/renderer.rs b/plugins/egui-baseview/src/renderer/opengl/renderer.rs similarity index 100% rename from crates/egui-baseview/src/renderer/opengl/renderer.rs rename to plugins/egui-baseview/src/renderer/opengl/renderer.rs diff --git a/crates/egui-baseview/src/translate.rs b/plugins/egui-baseview/src/translate.rs similarity index 100% rename from crates/egui-baseview/src/translate.rs rename to plugins/egui-baseview/src/translate.rs diff --git a/crates/egui-baseview/src/window.rs b/plugins/egui-baseview/src/window.rs similarity index 99% rename from crates/egui-baseview/src/window.rs rename to plugins/egui-baseview/src/window.rs index edbbea0..b85d6dc 100644 --- a/crates/egui-baseview/src/window.rs +++ b/plugins/egui-baseview/src/window.rs @@ -372,6 +372,11 @@ where } } + let actual_size = window.physical_size(); + if actual_size != self.physical_size { + self.physical_size = actual_size; + } + let now = Instant::now(); let do_repaint_now = if let Some(t) = self.repaint_after { now >= t || viewport_output.repaint_delay.is_zero() @@ -436,6 +441,7 @@ where baseview::MouseEvent::CursorMoved { position, modifiers, + .. } => { self.update_modifiers(modifiers); diff --git a/plugins/nih-plug-egui/Cargo.toml b/plugins/nih-plug-egui/Cargo.toml new file mode 100644 index 0000000..374337f --- /dev/null +++ b/plugins/nih-plug-egui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "nih_plug_egui" +version = "0.0.0" +edition = "2021" +authors = ["Robbert van der Helm "] +license = "ISC" + +description = "An adapter to use egui GUIs with NIH-plug" + +[features] +default = ["opengl", "default_fonts"] +# `nih_plug_egui` always uses OpenGL since egui's wgpu backend is still unstable +# depending on the platform +opengl = ["egui-baseview/opengl"] +default_fonts = ["egui-baseview/default_fonts"] + +[dependencies] +nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", default-features = false } +raw-window-handle = "0.5" +baseview = { path = "../baseview" } +crossbeam = "0.8" +egui-baseview = { path = "../egui-baseview" } +parking_lot = "0.12" +# To make the state persistable +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/nih-plug-egui/src/editor.rs b/plugins/nih-plug-egui/src/editor.rs new file mode 100644 index 0000000..d533937 --- /dev/null +++ b/plugins/nih-plug-egui/src/editor.rs @@ -0,0 +1,194 @@ +//! An [`Editor`] implementation for egui. + +use crate::egui::Vec2; +use crate::egui::ViewportCommand; +use crate::EguiState; +use baseview::gl::GlConfig; +use baseview::PhySize; +use baseview::{Size, WindowHandle, WindowOpenOptions, WindowScalePolicy}; +use crossbeam::atomic::AtomicCell; +use egui_baseview::egui::Context; +use egui_baseview::EguiWindow; +use nih_plug::prelude::{Editor, GuiContext, ParamSetter, ParentWindowHandle}; +use parking_lot::RwLock; +use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +/// An [`Editor`] implementation that calls an egui draw loop. +pub(crate) struct EguiEditor { + pub(crate) egui_state: Arc, + /// The plugin's state. This is kept in between editor openenings. + pub(crate) user_state: Arc>, + + /// The user's build function. Applied once at the start of the application. + pub(crate) build: Arc, + /// The user's update function. + pub(crate) update: Arc, + + /// The scaling factor reported by the host, if any. On macOS this will never be set and we + /// should use the system scaling factor instead. + pub(crate) scaling_factor: AtomicCell>, +} + +/// This version of `baseview` uses a different version of `raw_window_handle than NIH-plug, so we +/// need to adapt it ourselves. +struct ParentWindowHandleAdapter(nih_plug::editor::ParentWindowHandle); + +unsafe impl HasRawWindowHandle for ParentWindowHandleAdapter { + fn raw_window_handle(&self) -> RawWindowHandle { + match self.0 { + ParentWindowHandle::X11Window(window) => { + let mut handle = raw_window_handle::XcbWindowHandle::empty(); + handle.window = window; + RawWindowHandle::Xcb(handle) + } + ParentWindowHandle::AppKitNsView(ns_view) => { + let mut handle = raw_window_handle::AppKitWindowHandle::empty(); + handle.ns_view = ns_view; + RawWindowHandle::AppKit(handle) + } + ParentWindowHandle::Win32Hwnd(hwnd) => { + let mut handle = raw_window_handle::Win32WindowHandle::empty(); + handle.hwnd = hwnd; + RawWindowHandle::Win32(handle) + } + } + } +} + +impl Editor for EguiEditor +where + T: 'static + Send + Sync, +{ + fn spawn( + &self, + parent: ParentWindowHandle, + context: Arc, + ) -> Box { + let build = self.build.clone(); + let update = self.update.clone(); + let state = self.user_state.clone(); + let egui_state = self.egui_state.clone(); + + let (unscaled_width, unscaled_height) = self.egui_state.size(); + let scaling_factor = self.scaling_factor.load(); + let window = EguiWindow::open_parented( + &ParentWindowHandleAdapter(parent), + WindowOpenOptions { + title: String::from("egui window"), + // Baseview should be doing the DPI scaling for us + size: Size::new(unscaled_width as f64, unscaled_height as f64), + // NOTE: For some reason passing 1.0 here causes the UI to be scaled on macOS but + // not the mouse events. + scale: scaling_factor + .map(|factor| WindowScalePolicy::ScaleFactor(factor as f64)) + .unwrap_or(WindowScalePolicy::SystemScaleFactor), + + #[cfg(feature = "opengl")] + gl_config: Some(GlConfig { + version: (3, 2), + 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: true, + ..Default::default() + }), + }, + Default::default(), + state, + move |egui_ctx, _queue, state| build(egui_ctx, &mut state.write()), + move |egui_ctx, queue, state| { + let setter = ParamSetter::new(context.as_ref()); + + // If the window was requested to resize + if let Some(new_size) = egui_state.requested_size.swap(None) { + // Ask the plugin host to resize to self.size() + if context.request_resize() { + // Resize the content of egui window + queue.resize(PhySize::new(new_size.0, new_size.1)); + egui_ctx.send_viewport_cmd(ViewportCommand::InnerSize(Vec2::new( + new_size.0 as f32, + new_size.1 as f32, + ))); + + // Update the state + egui_state.size.store(new_size); + } + } + + // For now, just always redraw. Most plugin GUIs have meters, and those almost always + // need a redraw. Later we can try to be a bit more sophisticated about this. Without + // this we would also have a blank GUI when it gets first opened because most DAWs open + // their GUI while the window is still unmapped. + egui_ctx.request_repaint(); + (update)(egui_ctx, &setter, &mut state.write()); + }, + ); + + self.egui_state.open.store(true, Ordering::Release); + Box::new(EguiEditorHandle { + egui_state: self.egui_state.clone(), + window, + }) + } + + /// Size of the editor window + fn size(&self) -> (u32, u32) { + let new_size = self.egui_state.requested_size.load(); + // This method will be used to ask the host for new size. + // If the editor is currently being resized and new size hasn't been consumed and set yet, return new requested size. + if let Some(new_size) = new_size { + new_size + } else { + self.egui_state.size() + } + } + + fn set_scale_factor(&self, factor: f32) -> bool { + // If the editor is currently open then the host must not change the current HiDPI scale as + // we don't have a way to handle that. Ableton Live does this. + if self.egui_state.is_open() { + return false; + } + + self.scaling_factor.store(Some(factor)); + true + } + + fn param_value_changed(&self, _id: &str, _normalized_value: f32) { + // As mentioned above, for now we'll always force a redraw to allow meter widgets to work + // correctly. In the future we can use an `Arc` and only force a redraw when + // that boolean is set. + } + + fn param_modulation_changed(&self, _id: &str, _modulation_offset: f32) {} + + fn param_values_changed(&self) { + // Same + } +} + +/// The window handle used for [`EguiEditor`]. +struct EguiEditorHandle { + egui_state: Arc, + window: WindowHandle, +} + +/// The window handle enum stored within 'WindowHandle' contains raw pointers. Is there a way around +/// having this requirement? +unsafe impl Send for EguiEditorHandle {} + +impl Drop for EguiEditorHandle { + fn drop(&mut self) { + self.egui_state.open.store(false, Ordering::Release); + // XXX: This should automatically happen when the handle gets dropped, but apparently not + self.window.close(); + } +} diff --git a/plugins/nih-plug-egui/src/lib.rs b/plugins/nih-plug-egui/src/lib.rs new file mode 100644 index 0000000..f404a83 --- /dev/null +++ b/plugins/nih-plug-egui/src/lib.rs @@ -0,0 +1,119 @@ +//! [egui](https://github.com/emilk/egui) editor support for NIH plug. +//! +//! TODO: Proper usage example, for now check out the gain_gui example + +// See the comment in the main `nih_plug` crate +#![allow(clippy::type_complexity)] + +use crossbeam::atomic::AtomicCell; +use egui::Context; +use nih_plug::params::persist::PersistentField; +use nih_plug::prelude::{Editor, ParamSetter}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +#[cfg(not(feature = "opengl"))] +compile_error!("There's currently no software rendering support for egui"); + +/// Re-export for convenience. +pub use egui_baseview::egui; + +mod editor; +pub mod resizable_window; +pub mod widgets; + +/// Create an [`Editor`] instance using an [`egui`][::egui] GUI. Using the user state parameter is +/// optional, but it can be useful for keeping track of some temporary GUI-only settings. See the +/// `gui_gain` example for more information on how to use this. The [`EguiState`] passed to this +/// function contains the GUI's intitial size, and this is kept in sync whenever the GUI gets +/// resized. You can also use this to know if the GUI is open, so you can avoid performing +/// potentially expensive calculations while the GUI is not open. If you want this size to be +/// persisted when restoring a plugin instance, then you can store it in a `#[persist = "key"]` +/// field on your parameters struct. +/// +/// See [`EguiState::from_size()`]. +pub fn create_egui_editor( + egui_state: Arc, + user_state: T, + build: B, + update: U, +) -> Option> +where + T: 'static + Send + Sync, + B: Fn(&Context, &mut T) + 'static + Send + Sync, + U: Fn(&Context, &ParamSetter, &mut T) + 'static + Send + Sync, +{ + Some(Box::new(editor::EguiEditor { + egui_state, + user_state: Arc::new(RwLock::new(user_state)), + build: Arc::new(build), + update: Arc::new(update), + + // TODO: We can't get the size of the window when baseview does its own scaling, so if the + // host does not set a scale factor on Windows or Linux we should just use a factor of + // 1. That may make the GUI tiny but it also prevents it from getting cut off. + #[cfg(target_os = "macos")] + scaling_factor: AtomicCell::new(None), + #[cfg(not(target_os = "macos"))] + scaling_factor: AtomicCell::new(Some(1.0)), + })) +} + +/// State for an `nih_plug_egui` editor. +#[derive(Debug, Serialize, Deserialize)] +pub struct EguiState { + /// The window's size in logical pixels before applying `scale_factor`. + #[serde(with = "nih_plug::params::persist::serialize_atomic_cell")] + size: AtomicCell<(u32, u32)>, + + /// The new size of the window, if it was requested to resize by the GUI. + #[serde(skip)] + requested_size: AtomicCell>, + + /// Whether the editor's window is currently open. + #[serde(skip)] + open: AtomicBool, +} + +impl<'a> PersistentField<'a, EguiState> for Arc { + fn set(&self, new_value: EguiState) { + self.size.store(new_value.size.load()); + } + + fn map(&self, f: F) -> R + where + F: Fn(&EguiState) -> R, + { + f(self) + } +} + +impl EguiState { + /// Initialize the GUI's state. This value can be passed to [`create_egui_editor()`]. The window + /// size is in logical pixels, so before it is multiplied by the DPI scaling factor. + pub fn from_size(width: u32, height: u32) -> Arc { + Arc::new(EguiState { + size: AtomicCell::new((width, height)), + requested_size: Default::default(), + open: AtomicBool::new(false), + }) + } + + /// Returns a `(width, height)` pair for the current size of the GUI in logical pixels. + pub fn size(&self) -> (u32, u32) { + self.size.load() + } + + /// Whether the GUI is currently visible. + // Called `is_open()` instead of `open()` to avoid the ambiguity. + pub fn is_open(&self) -> bool { + self.open.load(Ordering::Acquire) + } + + /// Set the new size that will be used to resize the window if the host allows. + pub fn set_requested_size(&self, new_size: (u32, u32)) { + self.requested_size.store(Some(new_size)); + } +} diff --git a/plugins/nih-plug-egui/src/resizable_window.rs b/plugins/nih-plug-egui/src/resizable_window.rs new file mode 100644 index 0000000..6409397 --- /dev/null +++ b/plugins/nih-plug-egui/src/resizable_window.rs @@ -0,0 +1,81 @@ +//! Resizable window wrapper for Egui editor. + +use egui_baseview::egui::emath::GuiRounding; +use egui_baseview::egui::{InnerResponse, UiBuilder}; + +use crate::egui::{pos2, CentralPanel, Context, Id, Rect, Response, Sense, Ui, Vec2}; +use crate::EguiState; + +/// Adds a corner to the plugin window that can be dragged in order to resize it. +/// Resizing happens through plugin API, hence a custom implementation is needed. +pub struct ResizableWindow { + id: Id, + min_size: Vec2, +} + +impl ResizableWindow { + pub fn new(id_source: impl std::hash::Hash) -> Self { + Self { + id: Id::new(id_source), + min_size: Vec2::splat(16.0), + } + } + + /// Won't shrink to smaller than this + #[inline] + pub fn min_size(mut self, min_size: impl Into) -> Self { + self.min_size = min_size.into(); + self + } + + pub fn show( + self, + context: &Context, + egui_state: &EguiState, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + CentralPanel::default().show(context, move |ui| { + let ui_rect = ui.clip_rect(); + let mut content_ui = + ui.new_child(UiBuilder::new().max_rect(ui_rect).layout(*ui.layout())); + + let ret = add_contents(&mut content_ui); + + let corner_size = Vec2::splat(ui.visuals().resize_corner_size); + let corner_rect = Rect::from_min_size(ui_rect.max - corner_size, corner_size); + + let corner_response = ui.interact(corner_rect, self.id.with("corner"), Sense::drag()); + + if let Some(pointer_pos) = corner_response.interact_pointer_pos() { + let desired_size = (pointer_pos - ui_rect.min + 0.5 * corner_response.rect.size()) + .max(self.min_size); + + if corner_response.dragged() { + egui_state.set_requested_size(( + desired_size.x.round() as u32, + desired_size.y.round() as u32, + )); + } + } + + paint_resize_corner(&content_ui, &corner_response); + + ret + }) + } +} + +pub fn paint_resize_corner(ui: &Ui, response: &Response) { + let stroke = ui.style().interact(response).fg_stroke; + + let painter = ui.painter(); + let rect = response.rect.translate(-Vec2::splat(2.0)); // move away from the corner + let cp = rect.max.round_to_pixels(painter.pixels_per_point()); + + let mut w = 2.0; + + while w <= rect.width() && w <= rect.height() { + painter.line_segment([pos2(cp.x - w, cp.y), pos2(cp.x, cp.y - w)], stroke); + w += 4.0; + } +} diff --git a/plugins/nih-plug-egui/src/widgets.rs b/plugins/nih-plug-egui/src/widgets.rs new file mode 100644 index 0000000..70b12e5 --- /dev/null +++ b/plugins/nih-plug-egui/src/widgets.rs @@ -0,0 +1,12 @@ +//! Custom egui widgets for displaying parameter values. +//! +//! # Note +//! +//! None of these widgets are finalized, and their sizes or looks can change at any point. Feel free +//! to copy the widgets and modify them to your personal taste. + +pub mod generic_ui; +mod param_slider; +pub mod util; + +pub use param_slider::ParamSlider; diff --git a/plugins/nih-plug-egui/src/widgets/generic_ui.rs b/plugins/nih-plug-egui/src/widgets/generic_ui.rs new file mode 100644 index 0000000..1d77b93 --- /dev/null +++ b/plugins/nih-plug-egui/src/widgets/generic_ui.rs @@ -0,0 +1,72 @@ +//! A simple generic UI widget that renders all parameters in a [`Params`] object as a scrollable +//! list of sliders and labels. + +use std::sync::Arc; + +use egui_baseview::egui::{self, TextStyle, Ui, Vec2}; +use nih_plug::prelude::{Param, ParamFlags, ParamPtr, ParamSetter, Params}; + +use super::ParamSlider; + +/// A widget that can be used to create a generic UI with. This is used in conjuction with empty +/// structs to emulate existential types. +pub trait ParamWidget { + fn add_widget(&self, ui: &mut Ui, param: &P, setter: &ParamSetter); + + /// The same as [`add_widget()`][Self::add_widget()], but for a `ParamPtr`. + /// + /// # Safety + /// + /// Undefined behavior of the `ParamPtr` does not point to a valid parameter. + unsafe fn add_widget_raw(&self, ui: &mut Ui, param: &ParamPtr, setter: &ParamSetter) { + match param { + ParamPtr::FloatParam(p) => self.add_widget(ui, &**p, setter), + ParamPtr::IntParam(p) => self.add_widget(ui, &**p, setter), + ParamPtr::BoolParam(p) => self.add_widget(ui, &**p, setter), + ParamPtr::EnumParam(p) => self.add_widget(ui, &**p, setter), + } + } +} + +/// Create a generic UI using [`ParamSlider`]s. +pub struct GenericSlider; + +/// Create a scrollable generic UI using the specified widget. Takes up all the remaining vertical +/// space. +pub fn create( + ui: &mut Ui, + params: Arc, + setter: &ParamSetter, + widget: impl ParamWidget, +) { + let padding = Vec2::splat(ui.text_style_height(&TextStyle::Body) * 0.2); + egui::containers::ScrollArea::vertical() + // Take up all remaining space, use a wrapper container to adjust how much space that is + .auto_shrink([false, false]) + .show(ui, |ui| { + let mut first_widget = true; + for (_, param_ptr, _) in params.param_map().into_iter() { + let flags = unsafe { param_ptr.flags() }; + if flags.contains(ParamFlags::HIDE_IN_GENERIC_UI) { + continue; + } + + // This list looks weird without a little padding + if !first_widget { + ui.allocate_space(padding); + } + + ui.label(unsafe { param_ptr.name() }); + unsafe { widget.add_widget_raw(ui, ¶m_ptr, setter) }; + + first_widget = false; + } + }); +} + +impl ParamWidget for GenericSlider { + fn add_widget(&self, ui: &mut Ui, param: &P, setter: &ParamSetter) { + // Make these sliders a bit wider, else they look a bit odd + ui.add(ParamSlider::for_param(param, setter).with_width(100.0)); + } +} diff --git a/plugins/nih-plug-egui/src/widgets/param_slider.rs b/plugins/nih-plug-egui/src/widgets/param_slider.rs new file mode 100644 index 0000000..38b1214 --- /dev/null +++ b/plugins/nih-plug-egui/src/widgets/param_slider.rs @@ -0,0 +1,357 @@ +use std::sync::{Arc, LazyLock}; + +use egui_baseview::egui::emath::GuiRounding; +use egui_baseview::egui::{ + self, emath, vec2, Key, Response, Sense, Stroke, TextEdit, TextStyle, Ui, Vec2, Widget, + WidgetText, +}; +use nih_plug::prelude::{Param, ParamSetter}; +use parking_lot::Mutex; + +use super::util; + +/// When shift+dragging a parameter, one pixel dragged corresponds to this much change in the +/// noramlized parameter. +const GRANULAR_DRAG_MULTIPLIER: f32 = 0.0015; + +static DRAG_NORMALIZED_START_VALUE_MEMORY_ID: LazyLock = + LazyLock::new(|| egui::Id::new((file!(), 0))); +static DRAG_AMOUNT_MEMORY_ID: LazyLock = LazyLock::new(|| egui::Id::new((file!(), 1))); +static VALUE_ENTRY_MEMORY_ID: LazyLock = LazyLock::new(|| egui::Id::new((file!(), 2))); + +/// A slider widget similar to [`egui::widgets::Slider`] that knows about NIH-plug parameters ranges +/// and can get values for it. The slider supports double click and control click to reset, +/// shift+drag for granular dragging, text value entry by clicking on the value text. +/// +/// TODO: Vertical orientation +/// TODO: Check below for more input methods that should be added +/// TODO: Decouple the logic from the drawing so we can also do things like nobs without having to +/// repeat everything +/// TODO: Add WidgetInfo annotations for accessibility +#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] +pub struct ParamSlider<'a, P: Param> { + param: &'a P, + setter: &'a ParamSetter<'a>, + + draw_value: bool, + slider_width: Option, + + /// Will be set in the `ui()` function so we can request keyboard input focus on Alt+click. + keyboard_focus_id: Option, +} + +impl<'a, P: Param> ParamSlider<'a, P> { + /// Create a new slider for a parameter. Use the other methods to modify the slider before + /// passing it to [`Ui::add()`]. + pub fn for_param(param: &'a P, setter: &'a ParamSetter<'a>) -> Self { + Self { + param, + setter, + + draw_value: true, + slider_width: None, + + keyboard_focus_id: None, + } + } + + /// Don't draw the text slider's current value after the slider. + pub fn without_value(mut self) -> Self { + self.draw_value = false; + self + } + + /// Set a custom width for the slider. + pub fn with_width(mut self, width: f32) -> Self { + self.slider_width = Some(width); + self + } + + fn plain_value(&self) -> P::Plain { + self.param.modulated_plain_value() + } + + fn normalized_value(&self) -> f32 { + self.param.modulated_normalized_value() + } + + fn string_value(&self) -> String { + self.param.to_string() + } + + /// Enable the keyboard entry part of the widget. + fn begin_keyboard_entry(&self, ui: &Ui) { + ui.memory_mut(|mem| mem.request_focus(self.keyboard_focus_id.unwrap())); + + // Always initialize the field to the current value, that seems nicer than having to + // being typing from scratch + let value_entry_mutex = ui.memory_mut(|mem| { + mem.data + .get_temp_mut_or_default::>>(*VALUE_ENTRY_MEMORY_ID) + .clone() + }); + *value_entry_mutex.lock() = self.string_value(); + } + + fn keyboard_entry_active(&self, ui: &Ui) -> bool { + ui.memory(|mem| mem.has_focus(self.keyboard_focus_id.unwrap())) + } + + fn begin_drag(&self) { + self.setter.begin_set_parameter(self.param); + } + + fn set_normalized_value(&self, normalized: f32) { + // This snaps to the nearest plain value if the parameter is stepped in some way. + // TODO: As an optimization, we could add a `const CONTINUOUS: bool` to the parameter to + // avoid this normalized->plain->normalized conversion for parameters that don't need + // it + let value = self.param.preview_plain(normalized); + if value != self.plain_value() { + self.setter.set_parameter(self.param, value); + } + } + + /// Begin and end drag still need to be called when using this. Returns `false` if the string + /// could no tbe parsed. + fn set_from_string(&self, string: &str) -> bool { + match self.param.string_to_normalized_value(string) { + Some(normalized_value) => { + self.set_normalized_value(normalized_value); + true + } + None => false, + } + } + + /// Begin and end drag still need to be called when using this.. + fn reset_param(&self) { + self.setter + .set_parameter(self.param, self.param.default_plain_value()); + } + + fn granular_drag(&self, ui: &Ui, drag_delta: Vec2) { + // Remember the intial position when we started with the granular drag. This value gets + // reset whenever we have a normal itneraction with the slider. + let start_value = if Self::get_drag_amount_memory(ui) == 0.0 { + Self::set_drag_normalized_start_value_memory(ui, self.normalized_value()); + self.normalized_value() + } else { + Self::get_drag_normalized_start_value_memory(ui) + }; + + let total_drag_distance = drag_delta.x + Self::get_drag_amount_memory(ui); + Self::set_drag_amount_memory(ui, total_drag_distance); + + self.set_normalized_value( + (start_value + (total_drag_distance * GRANULAR_DRAG_MULTIPLIER)).clamp(0.0, 1.0), + ); + } + + fn end_drag(&self) { + self.setter.end_set_parameter(self.param); + } + + fn get_drag_normalized_start_value_memory(ui: &Ui) -> f32 { + ui.memory(|mem| { + mem.data + .get_temp(*DRAG_NORMALIZED_START_VALUE_MEMORY_ID) + .unwrap_or(0.5) + }) + } + + fn set_drag_normalized_start_value_memory(ui: &Ui, amount: f32) { + ui.memory_mut(|mem| { + mem.data + .insert_temp(*DRAG_NORMALIZED_START_VALUE_MEMORY_ID, amount) + }); + } + + fn get_drag_amount_memory(ui: &Ui) -> f32 { + ui.memory(|mem| mem.data.get_temp(*DRAG_AMOUNT_MEMORY_ID).unwrap_or(0.0)) + } + + fn set_drag_amount_memory(ui: &Ui, amount: f32) { + ui.memory_mut(|mem| mem.data.insert_temp(*DRAG_AMOUNT_MEMORY_ID, amount)); + } + + fn slider_ui(&self, ui: &Ui, response: &mut Response) { + // Handle user input + // TODO: Optionally (since it can be annoying) add scrolling behind a builder option + if response.drag_started() { + // When beginning a drag or dragging normally, reset the memory used to keep track of + // our granular drag + self.begin_drag(); + Self::set_drag_amount_memory(ui, 0.0); + } + if let Some(click_pos) = response.interact_pointer_pos() { + if ui.input(|i| i.modifiers.command) { + // Like double clicking, Ctrl+Click should reset the parameter + self.reset_param(); + response.mark_changed(); + // // FIXME: This releases the focus again when you release the mouse button without + // // moving the mouse a bit for some reason + // } else if ui.input().modifiers.alt && self.draw_value { + // // Allow typing in the value on an Alt+Click. Right now this is shown as part of the + // // value field, so it only makes sense when we're drawing that. + // self.begin_keyboard_entry(ui); + } else if ui.input(|i| i.modifiers.shift) { + // And shift dragging should switch to a more granulra input method + self.granular_drag(ui, response.drag_delta()); + response.mark_changed(); + } else { + let proportion = + emath::remap_clamp(click_pos.x, response.rect.x_range(), 0.0..=1.0) as f64; + self.set_normalized_value(proportion as f32); + response.mark_changed(); + Self::set_drag_amount_memory(ui, 0.0); + } + } + if response.double_clicked() { + self.reset_param(); + response.mark_changed(); + } + if response.drag_stopped() { + self.end_drag(); + } + + // And finally draw the thing + if ui.is_rect_visible(response.rect) { + // We'll do a flat widget with background -> filled foreground -> slight border + ui.painter() + .rect_filled(response.rect, 0.0, ui.visuals().widgets.inactive.bg_fill); + + let filled_proportion = self.normalized_value(); + if filled_proportion > 0.0 { + let mut filled_rect = response.rect; + filled_rect.set_width(response.rect.width() * filled_proportion); + let filled_bg = if response.dragged() { + util::add_hsv(ui.visuals().selection.bg_fill, 0.0, -0.1, 0.1) + } else { + ui.visuals().selection.bg_fill + }; + ui.painter().rect_filled(filled_rect, 0.0, filled_bg); + } + + ui.painter().rect_stroke( + response.rect, + 0.0, + Stroke::new(1.0, ui.visuals().widgets.active.bg_fill), + egui::StrokeKind::Middle, + ); + } + } + + fn value_ui(&self, ui: &mut Ui) { + let visuals = ui.visuals().widgets.inactive; + let should_draw_frame = ui.visuals().button_frame; + let padding = ui.spacing().button_padding; + + // Either show the parameter's label, or show a text entry field if the parameter's label + // has been clicked on + let keyboard_focus_id = self.keyboard_focus_id.unwrap(); + if self.keyboard_entry_active(ui) { + let value_entry_mutex = ui.memory_mut(|mem| { + mem.data + .get_temp_mut_or_default::>>(*VALUE_ENTRY_MEMORY_ID) + .clone() + }); + let mut value_entry = value_entry_mutex.lock(); + + ui.add( + TextEdit::singleline(&mut *value_entry) + .id(keyboard_focus_id) + .font(TextStyle::Monospace), + ); + if ui.input(|i| i.key_pressed(Key::Escape)) { + // Cancel when pressing escape + ui.memory_mut(|mem| mem.surrender_focus(keyboard_focus_id)); + } else if ui.input(|i| i.key_pressed(Key::Enter)) { + // And try to set the value by string when pressing enter + self.begin_drag(); + self.set_from_string(&value_entry); + self.end_drag(); + + ui.memory_mut(|mem| mem.surrender_focus(keyboard_focus_id)); + } + } else { + let text = WidgetText::from(self.string_value()).into_galley( + ui, + None, + ui.available_width() - (padding.x * 2.0), + TextStyle::Button, + ); + + let response = ui.allocate_response(text.size() + (padding * 2.0), Sense::click()); + if response.clicked() { + self.begin_keyboard_entry(ui); + } + + if ui.is_rect_visible(response.rect) { + if should_draw_frame { + let fill = visuals.bg_fill; + let stroke = visuals.bg_stroke; + ui.painter().rect( + response.rect.expand(visuals.expansion), + visuals.corner_radius, + fill, + stroke, + egui::StrokeKind::Middle, + ); + } + + let text_pos = ui + .layout() + .align_size_within_rect(text.size(), response.rect.shrink2(padding)) + .min; + + ui.painter().add(egui::epaint::TextShape::new( + text_pos, + text, + visuals.fg_stroke.color, + )); + } + } + } +} + +impl Widget for ParamSlider<'_, P> { + fn ui(mut self, ui: &mut Ui) -> Response { + let slider_width = self + .slider_width + .unwrap_or_else(|| ui.spacing().slider_width); + + ui.horizontal(|ui| { + // Allocate space, but add some padding on the top and bottom to make it look a bit slimmer. + let height = ui + .text_style_height(&TextStyle::Body) + .max(ui.spacing().interact_size.y * 0.8); + let slider_height = (height * 0.8).round_to_pixels(ui.painter().pixels_per_point()); + let mut response = ui + .vertical(|ui| { + ui.allocate_space(vec2(slider_width, (height - slider_height) / 2.0)); + let response = ui.allocate_response( + vec2(slider_width, slider_height), + Sense::click_and_drag(), + ); + let (kb_edit_id, _) = + ui.allocate_space(vec2(slider_width, (height - slider_height) / 2.0)); + // Allocate an automatic ID for keeping track of keyboard focus state + // FIXME: There doesn't seem to be a way to generate IDs in the public API, not sure how + // you're supposed to do this + self.keyboard_focus_id = Some(kb_edit_id); + + response + }) + .inner; + + self.slider_ui(ui, &mut response); + if self.draw_value { + self.value_ui(ui); + } + + response + }) + .inner + } +} diff --git a/plugins/nih-plug-egui/src/widgets/util.rs b/plugins/nih-plug-egui/src/widgets/util.rs new file mode 100644 index 0000000..f58cb43 --- /dev/null +++ b/plugins/nih-plug-egui/src/widgets/util.rs @@ -0,0 +1,21 @@ +//! Utilities for creating these widgets. + +use egui_baseview::egui::{self, Color32}; + +/// Additively modify the hue, saturation, and lightness [0, 1] values of a color. +pub fn add_hsv(color: Color32, h: f32, s: f32, v: f32) -> Color32 { + let mut hsv = egui::epaint::Hsva::from(color); + hsv.h += h; + hsv.s += s; + hsv.v += v; + hsv.into() +} + +/// Multiplicatively modify the hue, saturation, and lightness [0, 1] values of a color. +pub fn scale_hsv(color: Color32, h: f32, s: f32, v: f32) -> Color32 { + let mut hsv = egui::epaint::Hsva::from(color); + hsv.h *= h; + hsv.s *= s; + hsv.v *= v; + hsv.into() +} diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index af50e0a..c8255f0 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -309,6 +309,12 @@ impl App { .editor .set_completion_enabled(self.ui.show_completion); } + AppCommand::SetFont(f) => self.ui.font = f, + AppCommand::SetZoomFactor(z) => self.ui.zoom_factor = z, + AppCommand::SetWindowSize(w, h) => { + self.ui.window_width = w; + self.ui.window_height = h; + } AppCommand::ToggleLiveKeysFill => self.live_keys.flip_fill(), // Panel @@ -375,8 +381,8 @@ impl App { AppCommand::AudioRefreshDevices => self.audio.refresh_devices(), // Options page - AppCommand::OptionsNextFocus => self.options.next_focus(), - AppCommand::OptionsPrevFocus => self.options.prev_focus(), + AppCommand::OptionsNextFocus => self.options.next_focus(self.plugin_mode), + AppCommand::OptionsPrevFocus => self.options.prev_focus(self.plugin_mode), AppCommand::OptionsSetFocus(focus) => self.options.focus = focus, AppCommand::ToggleRefreshRate => self.audio.toggle_refresh_rate(), AppCommand::ToggleScope => self.audio.config.show_scope = !self.audio.config.show_scope, diff --git a/src/app/persistence.rs b/src/app/persistence.rs index ae427d6..6383e3f 100644 --- a/src/app/persistence.rs +++ b/src/app/persistence.rs @@ -30,7 +30,8 @@ impl App { layout: self.audio.config.layout, hue_rotation: self.ui.hue_rotation, onboarding_dismissed: self.ui.onboarding_dismissed.clone(), - ..Default::default() + font: self.ui.font.clone(), + zoom_factor: self.ui.zoom_factor, }, link: crate::settings::LinkSettings { enabled: link.is_enabled(), diff --git a/src/commands.rs b/src/commands.rs index bab13b9..954f4c2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -199,6 +199,9 @@ pub enum AppCommand { SetHueRotation(f32), ToggleRuntimeHighlight, ToggleCompletion, + SetFont(String), + SetZoomFactor(f32), + SetWindowSize(u32, u32), // Live keys ToggleLiveKeysFill, diff --git a/src/init.rs b/src/init.rs index 379c554..90364e5 100644 --- a/src/init.rs +++ b/src/init.rs @@ -88,6 +88,8 @@ pub fn init(args: InitArgs) -> Init { app.ui.hue_rotation = settings.display.hue_rotation; app.audio.config.layout = settings.display.layout; app.ui.onboarding_dismissed = settings.display.onboarding_dismissed.clone(); + app.ui.font = settings.display.font.clone(); + app.ui.zoom_factor = settings.display.zoom_factor; let palette = settings.display.color_scheme.to_palette(); let rotated = diff --git a/src/input/mouse.rs b/src/input/mouse.rs index a4a6cb4..dcae527 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -675,8 +675,9 @@ fn handle_options_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) } let focus = ctx.app.options.focus; - let focus_line = focus.line_index(); - let total_lines = 35; + let plugin_mode = ctx.app.plugin_mode; + let focus_line = focus.line_index(plugin_mode); + let total_lines = if plugin_mode { 43 } else { 40 }; let max_visible = padded.height as usize; let scroll_offset = if total_lines <= max_visible { @@ -690,7 +691,7 @@ fn handle_options_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) let relative_y = (row - padded.y) as usize; let abs_line = scroll_offset + relative_y; - if let Some(new_focus) = OptionsFocus::at_line(abs_line) { + if let Some(new_focus) = OptionsFocus::at_line(abs_line, plugin_mode) { ctx.dispatch(AppCommand::OptionsSetFocus(new_focus)); // Value area starts at prefix(2) + label(20) = offset 22 from padded.x diff --git a/src/input/options_page.rs b/src/input/options_page.rs index 5fe867e..cdaf411 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -26,6 +26,56 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum), OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion), OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview), + OptionsFocus::Font => { + const FONTS: &[&str] = &["6x13", "7x13", "8x13", "9x15", "9x18", "10x20"]; + let pos = FONTS.iter().position(|f| *f == ctx.app.ui.font).unwrap_or(2); + let new_pos = if right { + (pos + 1) % FONTS.len() + } else { + (pos + FONTS.len() - 1) % FONTS.len() + }; + ctx.dispatch(AppCommand::SetFont(FONTS[new_pos].to_string())); + } + OptionsFocus::ZoomFactor => { + const ZOOMS: &[f32] = &[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; + let pos = ZOOMS + .iter() + .position(|z| (z - ctx.app.ui.zoom_factor).abs() < 0.01) + .unwrap_or(4); + let new_pos = if right { + (pos + 1) % ZOOMS.len() + } else { + (pos + ZOOMS.len() - 1) % ZOOMS.len() + }; + ctx.dispatch(AppCommand::SetZoomFactor(ZOOMS[new_pos])); + } + OptionsFocus::WindowSize => { + const WINDOW_SIZES: &[(u32, u32)] = &[ + (900, 600), (1050, 700), (1200, 800), (1350, 900), (1500, 1000), + ]; + let pos = WINDOW_SIZES + .iter() + .position(|&(w, h)| w == ctx.app.ui.window_width && h == ctx.app.ui.window_height) + .unwrap_or_else(|| { + WINDOW_SIZES + .iter() + .enumerate() + .min_by_key(|&(_, &(w, h))| { + let dw = w as i64 - ctx.app.ui.window_width as i64; + let dh = h as i64 - ctx.app.ui.window_height as i64; + dw * dw + dh * dh + }) + .map(|(i, _)| i) + .unwrap_or(2) + }); + let new_pos = if right { + (pos + 1) % WINDOW_SIZES.len() + } else { + (pos + WINDOW_SIZES.len() - 1) % WINDOW_SIZES.len() + }; + let (w, h) = WINDOW_SIZES[new_pos]; + ctx.dispatch(AppCommand::SetWindowSize(w, h)); + } OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), OptionsFocus::StartStopSync => ctx .link diff --git a/src/state/options.rs b/src/state/options.rs index 680f0fa..a671566 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -11,6 +11,9 @@ pub enum OptionsFocus { ShowSpectrum, ShowCompletion, ShowPreview, + Font, + ZoomFactor, + WindowSize, LinkEnabled, StartStopSync, Quantum, @@ -35,6 +38,9 @@ impl CyclicEnum for OptionsFocus { Self::ShowSpectrum, Self::ShowCompletion, Self::ShowPreview, + Self::Font, + Self::ZoomFactor, + Self::WindowSize, Self::LinkEnabled, Self::StartStopSync, Self::Quantum, @@ -50,6 +56,8 @@ impl CyclicEnum for OptionsFocus { ]; } +// Line indices when Font/ZoomFactor are shown (plugin mode). +// In terminal mode, Font/ZoomFactor are absent; all lines after ShowPreview shift up by 2. const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ (OptionsFocus::ColorScheme, 2), (OptionsFocus::HueRotation, 3), @@ -59,34 +67,51 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ (OptionsFocus::ShowSpectrum, 7), (OptionsFocus::ShowCompletion, 8), (OptionsFocus::ShowPreview, 9), - (OptionsFocus::LinkEnabled, 13), - (OptionsFocus::StartStopSync, 14), - (OptionsFocus::Quantum, 15), - (OptionsFocus::MidiOutput0, 25), - (OptionsFocus::MidiOutput1, 26), - (OptionsFocus::MidiOutput2, 27), - (OptionsFocus::MidiOutput3, 28), - (OptionsFocus::MidiInput0, 32), - (OptionsFocus::MidiInput1, 33), - (OptionsFocus::MidiInput2, 34), - (OptionsFocus::MidiInput3, 35), - (OptionsFocus::ResetOnboarding, 39), + (OptionsFocus::Font, 10), + (OptionsFocus::ZoomFactor, 11), + (OptionsFocus::WindowSize, 12), + (OptionsFocus::LinkEnabled, 16), + (OptionsFocus::StartStopSync, 17), + (OptionsFocus::Quantum, 18), + (OptionsFocus::MidiOutput0, 28), + (OptionsFocus::MidiOutput1, 29), + (OptionsFocus::MidiOutput2, 30), + (OptionsFocus::MidiOutput3, 31), + (OptionsFocus::MidiInput0, 35), + (OptionsFocus::MidiInput1, 36), + (OptionsFocus::MidiInput2, 37), + (OptionsFocus::MidiInput3, 38), + (OptionsFocus::ResetOnboarding, 42), ]; +const PLUGIN_ONLY: &[OptionsFocus] = &[OptionsFocus::Font, OptionsFocus::ZoomFactor, OptionsFocus::WindowSize]; + impl OptionsFocus { - pub fn line_index(self) -> usize { - FOCUS_LINES + fn is_plugin_only(self) -> bool { + PLUGIN_ONLY.contains(&self) + } + + pub fn line_index(self, plugin_mode: bool) -> usize { + let base = FOCUS_LINES .iter() .find(|(f, _)| *f == self) .map(|(_, l)| *l) - .unwrap_or(0) + .unwrap_or(0); + if plugin_mode || base <= 9 { + base + } else { + base - 3 + } } - pub fn at_line(line: usize) -> Option { - FOCUS_LINES - .iter() - .find(|(_, l)| *l == line) - .map(|(f, _)| *f) + pub fn at_line(line: usize, plugin_mode: bool) -> Option { + FOCUS_LINES.iter().find_map(|(f, l)| { + if f.is_plugin_only() && !plugin_mode { + return None; + } + let effective = if plugin_mode || *l <= 9 { *l } else { *l - 3 }; + if effective == line { Some(*f) } else { None } + }) } } @@ -96,11 +121,25 @@ pub struct OptionsState { } impl OptionsState { - pub fn next_focus(&mut self) { - self.focus = self.focus.next(); + pub fn next_focus(&mut self, plugin_mode: bool) { + let mut f = self.focus; + loop { + f = f.next(); + if !f.is_plugin_only() || plugin_mode { + break; + } + } + self.focus = f; } - pub fn prev_focus(&mut self) { - self.focus = self.focus.prev(); + pub fn prev_focus(&mut self, plugin_mode: bool) { + let mut f = self.focus; + loop { + f = f.prev(); + if !f.is_plugin_only() || plugin_mode { + break; + } + } + self.focus = f; } } diff --git a/src/state/ui.rs b/src/state/ui.rs index 1053e4d..3c275bf 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -74,6 +74,10 @@ pub struct UiState { pub prev_page: Page, pub prev_show_title: bool, pub onboarding_dismissed: Vec, + pub font: String, + pub zoom_factor: f32, + pub window_width: u32, + pub window_height: u32, } impl Default for UiState { @@ -117,6 +121,10 @@ impl Default for UiState { prev_page: Page::default(), prev_show_title: true, onboarding_dismissed: Vec::new(), + font: "8x13".to_string(), + zoom_factor: 1.5, + window_width: 1200, + window_height: 800, } } } diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 36c1a97..1ce9da5 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -113,7 +113,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let onboarding_str = format!("{}/6 dismissed", app.ui.onboarding_dismissed.len()); let hue_str = format!("{}°", app.ui.hue_rotation as i32); - let lines: Vec = vec![ + let mut lines: Vec = vec![ render_section_header("DISPLAY", &theme), render_divider(content_width, &theme), render_option_line( @@ -168,7 +168,31 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { focus == OptionsFocus::ShowPreview, &theme, ), - Line::from(""), + ]; + let zoom_str = format!("{:.0}%", app.ui.zoom_factor * 100.0); + let window_str = format!("{}x{}", app.ui.window_width, app.ui.window_height); + if app.plugin_mode { + lines.push(render_option_line( + "Font", + &app.ui.font, + focus == OptionsFocus::Font, + &theme, + )); + lines.push(render_option_line( + "Zoom", + &zoom_str, + focus == OptionsFocus::ZoomFactor, + &theme, + )); + lines.push(render_option_line( + "Window", + &window_str, + focus == OptionsFocus::WindowSize, + &theme, + )); + } + lines.push(Line::from("")); + lines.extend([ link_header, render_divider(content_width, &theme), render_option_line( @@ -217,12 +241,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { focus == OptionsFocus::ResetOnboarding, &theme, ), - ]; + ]); let total_lines = lines.len(); let max_visible = padded.height as usize; - let focus_line = focus.line_index(); + let focus_line = focus.line_index(app.plugin_mode); let scroll_offset = if total_lines <= max_visible { 0