diff --git a/changelog.d/10234-macos-leaf-padding.md b/changelog.d/10234-macos-leaf-padding.md new file mode 100644 index 0000000000..7965cceb43 --- /dev/null +++ b/changelog.d/10234-macos-leaf-padding.md @@ -0,0 +1,6 @@ +Make macOS `setPadding` work on buttons, Text labels, and AttributedText labels, +including intrinsic sizing and asymmetric content placement. Preserve native +button cells, styling, and target/action wiring, and invalidate layout when +padding changes. Respect flipped AppKit coordinates for text-field insets. +Unsupported native views now produce a diagnostic in development builds; wrap +them in a padded stack. Add native size and rendered-content regression tests. diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index ea8294a1de..332d166c05 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -80,3 +80,8 @@ perry-runtime.workspace = true name = "native_widget_order" path = "tests/native_widget_order.rs" harness = false + +[[test]] +name = "native_widget_padding" +path = "tests/native_widget_padding.rs" +harness = false diff --git a/crates/perry-ui-macos/src/drag_drop.rs b/crates/perry-ui-macos/src/drag_drop.rs index 26fb12c36d..261da4b368 100644 --- a/crates/perry-ui-macos/src/drag_drop.rs +++ b/crates/perry-ui-macos/src/drag_drop.rs @@ -112,13 +112,24 @@ unsafe fn call_provider(cb: f64) -> Option { /// to it (idempotent). unsafe fn ensure_swizzled(view: *mut AnyObject) { let cls = (*view).class(); - if cls.name().to_bytes().starts_with(b"PerryDragDrop_") { + if drag_drop_class(cls).is_some() { return; // already swizzled } let sub = get_or_create_subclass(cls); objc2::ffi::object_setClass(view, sub as *const AnyClass as *mut AnyClass); } +// Padding can add a subclass above ours. Find the class that owns our +// methods instead of wrapping it twice or losing mouseDown forwarding. +fn drag_drop_class(mut cls: &'static AnyClass) -> Option<&'static AnyClass> { + loop { + if cls.name().to_bytes().starts_with(b"PerryDragDrop_") { + return Some(cls); + } + cls = cls.superclass()?; + } +} + unsafe fn get_or_create_subclass(orig: &AnyClass) -> &'static AnyClass { let sub_name = format!("PerryDragDrop_{}", orig.name().to_str().unwrap_or("View")); let c_name = CString::new(sub_name).unwrap(); @@ -300,7 +311,7 @@ extern "C-unwind" fn mouse_down(this: *mut AnyObject, cmd: Sel, event: *mut AnyO // Not a drag source — forward to the original class's mouseDown: so the // underlying control (button, text field, …) keeps behaving normally. unsafe { - let sub = (*this).class(); + let sub = drag_drop_class((*this).class()).expect("drag/drop method owner"); let imp = ORIG_MOUSEDOWN.with(|m| m.borrow().get(&(sub as *const AnyClass as usize)).copied()); if let Some(imp) = imp { diff --git a/crates/perry-ui-macos/src/widgets/attributed_text.rs b/crates/perry-ui-macos/src/widgets/attributed_text.rs index 4ee901df96..d83e12b70d 100644 --- a/crates/perry-ui-macos/src/widgets/attributed_text.rs +++ b/crates/perry-ui-macos/src/widgets/attributed_text.rs @@ -34,6 +34,7 @@ pub fn create() -> i64 { let mtm = MainThreadMarker::new().expect("perry/ui must run on the main thread"); let empty = NSString::from_str(""); let label = NSTextField::labelWithString(&empty, mtm); + super::padding::install_label_cell(&label, mtm); unsafe { let _: () = msg_send![&*label, setTranslatesAutoresizingMaskIntoConstraints: false]; // Enable wrapping by default — per-range styling is most useful diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index 49112aa37c..de62433062 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -6,6 +6,8 @@ use objc2_core_foundation::CGRect; use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol}; use std::cell::Cell; +mod button; + pub struct PerryInsetCellIvars { top: Cell, left: Cell, @@ -49,14 +51,14 @@ define_class!( impl PerryInsetTextFieldCell { #[unsafe(method(drawingRectForBounds:))] fn drawing_rect_for_bounds(&self, bounds: CGRect) -> CGRect { - let bounds = inset_rect(bounds, self.ivars().get()); + let bounds = inset_rect(bounds, self.ivars().get(), unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); - let bounds = inset_rect(bounds, insets); + let bounds = inset_rect(bounds, insets, unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); let size: objc2_core_foundation::CGSize = unsafe { msg_send![super(self), cellSizeForBounds: bounds] }; padded_size(size, insets) @@ -71,7 +73,7 @@ define_class!( delegate: Option<&AnyObject>, event: Option<&NSEvent>, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), editWithFrame: frame, inView: view, editor: editor, delegate: delegate, event: event]; } @@ -85,7 +87,7 @@ define_class!( start: isize, length: isize, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), selectWithFrame: frame, inView: view, editor: editor, delegate: delegate, start: start, length: length]; } @@ -105,14 +107,14 @@ define_class!( impl PerryInsetSecureTextFieldCell { #[unsafe(method(drawingRectForBounds:))] fn drawing_rect_for_bounds(&self, bounds: CGRect) -> CGRect { - let bounds = inset_rect(bounds, self.ivars().get()); + let bounds = inset_rect(bounds, self.ivars().get(), unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); - let bounds = inset_rect(bounds, insets); + let bounds = inset_rect(bounds, insets, unsafe { self.controlView() }.is_some_and(|view| view.isFlipped())); let size: objc2_core_foundation::CGSize = unsafe { msg_send![super(self), cellSizeForBounds: bounds] }; padded_size(size, insets) @@ -127,7 +129,7 @@ define_class!( delegate: Option<&AnyObject>, event: Option<&NSEvent>, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), editWithFrame: frame, inView: view, editor: editor, delegate: delegate, event: event]; } @@ -141,7 +143,7 @@ define_class!( start: isize, length: isize, ) { - let frame = inset_rect(frame, self.ivars().get()); + let frame = inset_rect(frame, self.ivars().get(), view.isFlipped()); let _: () = msg_send![super(self), selectWithFrame: frame, inView: view, editor: editor, delegate: delegate, start: start, length: length]; } @@ -152,12 +154,13 @@ define_class!( } ); -fn inset_rect(rect: CGRect, insets: NSEdgeInsets) -> CGRect { - // AppKit's unflipped cell coordinates grow upward, so bottom moves origin.y. +fn inset_rect(rect: CGRect, insets: NSEdgeInsets, flipped: bool) -> CGRect { + // Native text fields and buttons are flipped: their top moves origin.y. + // Keep bottom-origin coordinates correct for an unflipped control view. CGRect::new( objc2_core_foundation::CGPoint::new( rect.origin.x + insets.left, - rect.origin.y + insets.bottom, + rect.origin.y + if flipped { insets.top } else { insets.bottom }, ), objc2_core_foundation::CGSize::new( (rect.size.width - insets.left - insets.right).max(0.0), @@ -200,9 +203,31 @@ pub(crate) fn install_secure_text_field_cell(field: &NSTextField, mtm: MainThrea } } +/// Install at label creation, before callers apply attributed text or styles. +/// Keep the factory label's text, font and line-breaking defaults. +pub(crate) fn install_label_cell(field: &NSTextField, mtm: MainThreadMarker) { + let value = field.attributedStringValue(); + let font = field.font(); + let original = field.cell().expect("label has a cell"); + install_text_field_cell(field, mtm); + field.setBezeled(false); + field.setBordered(false); + field.setEditable(false); + field.setSelectable(false); + field.setDrawsBackground(false); + field.setFont(font.as_deref()); + field.setAttributedStringValue(&value); + if let Some(cell) = field.cell() { + cell.setWraps(original.wraps()); + cell.setScrollable(original.isScrollable()); + cell.setUsesSingleLineMode(original.usesSingleLineMode()); + cell.setLineBreakMode(original.lineBreakMode()); + } +} + /// Apply padding to AppKit widgets with a native content-inset mechanism. -/// NSButton, NSTextField labels, and NSImageView do not expose one and remain -/// explicit no-ops until Perry gives those leaf widgets content wrappers. +/// Perry's labels and buttons include padding in their native sizing and +/// drawing paths, without replacing the widget or its target/action. pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, right: f64) { let insets = NSEdgeInsets { top, @@ -218,6 +243,11 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r } } + if AnyClass::get(c"NSButton").is_some_and(|cls| view.isKindOfClass(cls)) { + button::set_insets(view, insets); + return; + } + if let Some(cls) = AnyClass::get(c"NSTextField") { if view.isKindOfClass(cls) { let cell: *mut AnyObject = msg_send![view, cell]; @@ -227,10 +257,11 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r let responds: bool = msg_send![cell, respondsToSelector: selector]; if responds { let _: () = msg_send![cell, setPerryInsetsTop: top, left: left, bottom: bottom, right: right]; + let _: () = msg_send![view, invalidateIntrinsicContentSize]; let _: () = msg_send![view, setNeedsDisplay: true]; + return; } } - return; } } @@ -248,9 +279,15 @@ pub(crate) fn set_edge_insets(view: &NSView, top: f64, left: f64, bottom: f64, r // TextArea is registered as its enclosing NSScrollView, and // NSScrollView's four-sided contentInsets preserve asymmetry. let _: () = msg_send![view, setContentInsets: insets]; + return; } } } + #[cfg(debug_assertions)] + eprintln!( + "[perry/ui] setPadding is not supported for {}; place the widget in a padded stack", + view.class() + ); } #[cfg(test)] @@ -271,6 +308,7 @@ mod tests { bottom: 7.0, right: 14.0, }, + false, ); assert_eq!(got.origin.x, 10.0); assert_eq!(got.origin.y, 10.0); diff --git a/crates/perry-ui-macos/src/widgets/padding/button.rs b/crates/perry-ui-macos/src/widgets/padding/button.rs new file mode 100644 index 0000000000..0663ab5d11 --- /dev/null +++ b/crates/perry-ui-macos/src/widgets/padding/button.rs @@ -0,0 +1,129 @@ +//! Per-instance button padding, following the backend's drag/drop subclass +//! pattern. No cell or view is reconstructed: AppKit's factory state survives. +use super::{inset_rect, padded_size}; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject, Bool, ClassBuilder, Sel}; +use objc2::{msg_send, sel}; +use objc2_app_kit::{NSButton, NSButtonCell, NSView}; +use objc2_core_foundation::{CGRect, CGSize}; +use objc2_foundation::{NSEdgeInsets, NSValue}; +use std::ffi::CString; + +static INSETS_KEY: u8 = 0; +const CLASS_PREFIX: &[u8] = b"PerryButtonPadding_"; + +fn padding_class(view: &NSView) -> Option<&'static AnyClass> { + let mut cls = Some(view.class()); + while let Some(current) = cls { + if current.name().to_bytes().starts_with(CLASS_PREFIX) { + return Some(current); + } + cls = current.superclass(); + } + None +} + +pub(super) fn set_insets(view: &NSView, insets: NSEdgeInsets) { + unsafe { + if padding_class(view).is_none() { + let original = view.class(); + let name = CString::new(format!( + "PerryButtonPadding_{}", + original.name().to_string_lossy() + )) + .unwrap(); + let subclass = AnyClass::get(&name).unwrap_or_else(|| { + let mut builder = + ClassBuilder::new(&name, original).expect("padding subclass name"); + builder.add_method( + sel!(intrinsicContentSize), + intrinsic_size as unsafe extern "C-unwind" fn(*mut NSButton, Sel) -> CGSize, + ); + builder.add_method( + sel!(wantsUpdateLayer), + wants_update_layer as unsafe extern "C-unwind" fn(*mut NSButton, Sel) -> Bool, + ); + builder.add_method( + sel!(drawRect:), + draw_rect as unsafe extern "C-unwind" fn(*mut NSButton, Sel, CGRect), + ); + builder.register() + }); + // Main-thread-only NSView access; the subclass adds no ivars and + // all method signatures match NSButton. It also composes with + // drag/drop's dynamic subclasses, before or after this call. + assert_eq!(original.instance_size(), subclass.instance_size()); + let previous = AnyObject::set_class(view, subclass); + assert_eq!(previous, original); + } + // The association is owned by the native view, so it is released with + // the view and cannot leak or be reused for a different widget address. + let value = NSValue::new(insets); + objc2::ffi::objc_setAssociatedObject( + view as *const NSView as *mut AnyObject, + (&INSETS_KEY as *const u8).cast(), + Retained::as_ptr(&value) as *mut AnyObject, + objc2::ffi::OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ); + view.invalidateIntrinsicContentSize(); + view.setNeedsDisplay(true); + } +} + +unsafe fn insets(button: &NSButton) -> NSEdgeInsets { + let ptr = objc2::ffi::objc_getAssociatedObject( + button as *const NSButton as *const AnyObject, + (&INSETS_KEY as *const u8).cast(), + ); + if ptr.is_null() { + return NSEdgeInsets { + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + }; + } + // This private association key stores only NSValue. + (&*(ptr as *const NSValue)).get() +} + +fn superclass(button: &NSButton) -> &'static AnyClass { + padding_class(button) + .expect("padding method owner") + .superclass() + .unwrap() +} + +unsafe extern "C-unwind" fn intrinsic_size(button: *mut NSButton, _: Sel) -> CGSize { + let button = &*button; + let size = msg_send![super(button, superclass(button)), intrinsicContentSize]; + padded_size(size, insets(button)) +} + +unsafe extern "C-unwind" fn wants_update_layer(button: *mut NSButton, _: Sel) -> Bool { + let button = &*button; + let i = insets(button); + if i.top != 0.0 || i.left != 0.0 || i.bottom != 0.0 || i.right != 0.0 { + Bool::NO + } else { + msg_send![super(button, superclass(button)), wantsUpdateLayer] + } +} + +unsafe extern "C-unwind" fn draw_rect(button: *mut NSButton, _: Sel, dirty: CGRect) { + let button = &*button; + let i = insets(button); + if i.top == 0.0 && i.left == 0.0 && i.bottom == 0.0 && i.right == 0.0 { + let _: () = msg_send![super(button, superclass(button)), drawRect: dirty]; + return; + } + if let Some(cell) = button.cell() { + let cell = &*(Retained::as_ptr(&cell) as *const NSButtonCell); + let bounds = button.bounds(); + if button.isBordered() { + cell.drawBezelWithFrame_inView(bounds, button); + } + let content = inset_rect(bounds, i, button.isFlipped()); + cell.drawInteriorWithFrame_inView(content, button); + } +} diff --git a/crates/perry-ui-macos/src/widgets/text.rs b/crates/perry-ui-macos/src/widgets/text.rs index 99ec35bffb..893ba00375 100644 --- a/crates/perry-ui-macos/src/widgets/text.rs +++ b/crates/perry-ui-macos/src/widgets/text.rs @@ -16,6 +16,7 @@ pub fn create(text_ptr: *const u8) -> i64 { let ns_string = NSString::from_str(&text); let label = NSTextField::labelWithString(&ns_string, mtm); + super::padding::install_label_cell(&label, mtm); unsafe { let _: () = objc2::msg_send![&*label, setAccessibilityLabel: &*ns_string]; // Disable autoresizing mask so Auto Layout can size this view in NSStackView. diff --git a/crates/perry-ui-macos/tests/native_widget_padding.rs b/crates/perry-ui-macos/tests/native_widget_padding.rs new file mode 100644 index 0000000000..641802dac3 --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_padding.rs @@ -0,0 +1,174 @@ +#[cfg(target_os = "macos")] +fn main() { + use objc2::msg_send; + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSButton, NSButtonCell, NSTextField, NSView}; + use objc2_core_foundation::CGSize; + use objc2_foundation::{MainThreadMarker, NSString}; + use perry_ui_macos::widgets; + + if std::env::args().any(|arg| arg == "--list") { + println!("native_widget_padding: test"); + return; + } + let mtm = MainThreadMarker::new().expect("native widget test runs on the main thread"); + let _app = NSApplication::sharedApplication(mtm); + let text = "Padding"; + let string = perry_runtime::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let label = widgets::text::create(string.cast()); + let button = widgets::button::create(string.cast(), 0.0); + let button_view = widgets::get_widget(button).unwrap(); + let native_button = unsafe { &*(Retained::as_ptr(&button_view) as *const NSButton) }; + let factory_button = unsafe { + NSButton::buttonWithTitle_target_action(&NSString::from_str(text), None, None, mtm) + }; + assert_eq!( + native_button.intrinsicContentSize(), + factory_button.intrinsicContentSize() + ); + assert_eq!(native_button.isBordered(), factory_button.isBordered()); + assert_eq!(native_button.bezelStyle(), factory_button.bezelStyle()); + assert_eq!(native_button.title(), factory_button.title()); + let cell = native_button.cell().unwrap(); + let factory_cell = factory_button.cell().unwrap(); + let cell = unsafe { &*(Retained::as_ptr(&cell) as *const NSButtonCell) }; + let factory_cell = unsafe { &*(Retained::as_ptr(&factory_cell) as *const NSButtonCell) }; + assert_eq!(cell.highlightsBy(), factory_cell.highlightsBy()); + assert_eq!(cell.showsStateBy(), factory_cell.showsStateBy()); + let target: *mut objc2::runtime::AnyObject = unsafe { msg_send![native_button, target] }; + let action = native_button.action(); + assert!(!target.is_null() && action.is_some()); + let bordered_size = native_button.intrinsicContentSize(); + widgets::set_edge_insets(button, 3.0, 5.0, 7.0, 11.0); + assert_eq!( + native_button.intrinsicContentSize(), + CGSize::new(bordered_size.width + 16.0, bordered_size.height + 10.0) + ); + ink_bounds( + native_button, + native_button.intrinsicContentSize(), + "bordered-padded", + ); + widgets::set_edge_insets(button, 0.0, 0.0, 0.0, 0.0); + assert_eq!(native_button.intrinsicContentSize(), bordered_size); + assert_eq!(native_button.bezelStyle(), factory_button.bezelStyle()); + let label_view = widgets::get_widget(label).unwrap(); + let native_label = unsafe { &*(Retained::as_ptr(&label_view) as *const NSTextField) }; + let factory_label = NSTextField::labelWithString(&NSString::from_str(text), mtm); + assert_eq!( + native_label.intrinsicContentSize(), + factory_label.intrinsicContentSize() + ); + assert_eq!(native_label.stringValue(), factory_label.stringValue()); + assert_eq!(native_label.isEditable(), factory_label.isEditable()); + assert_eq!(native_label.isSelectable(), factory_label.isSelectable()); + assert_eq!( + native_label.drawsBackground(), + factory_label.drawsBackground() + ); + let attributed = widgets::attributed_text::create(); + widgets::attributed_text::append(attributed, string.cast(), 1, 0, 1, 18.0, 0.0, 0.0, 0.0, 1.0); + widgets::button::set_bordered(button, false); + let mut failures = Vec::new(); + for (name, handle, layer) in [ + ("label", label, false), + ("button", button, false), + ("label-layer", label, true), + ("button-layer", button, true), + ("attributed", attributed, true), + ] { + let view = widgets::get_widget(handle).unwrap(); + view.setWantsLayer(layer); + let before: CGSize = unsafe { msg_send![&*view, intrinsicContentSize] }; + let ink_before = ink_bounds(&view, before, &format!("{name}-before")); + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + let after: CGSize = unsafe { msg_send![&*view, intrinsicContentSize] }; + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + assert_eq!( + view.intrinsicContentSize(), + after, + "padding is replaced, never accumulated" + ); + let ink_after = ink_bounds(&view, after, &format!("{name}-after")); + println!("{name}: {before:?} -> {after:?}"); + println!( + "{name} ink: {ink_before:?} -> {ink_after:?}, flipped={}", + view.isFlipped() + ); + for (actual, expected) in [ + (ink_after.0 - ink_before.0, 5.0), + (ink_after.1 - ink_before.1, 3.0), + (ink_after.2 - ink_before.2, 5.0), + (ink_after.3 - ink_before.3, 3.0), + ] { + assert!( + (actual - expected).abs() < 0.51, + "{name}: content must move by top/left padding without being clipped" + ); + } + if (after.width - before.width - 16.0).abs() > 0.01 + || (after.height - before.height - 10.0).abs() > 0.01 + { + failures.push(name); + } + widgets::set_edge_insets(handle, 0.0, 0.0, 0.0, 0.0); + let reset: CGSize = unsafe { msg_send![&*view, intrinsicContentSize] }; + assert_eq!(reset, before, "resetting {name} padding restores its size"); + } + assert!(failures.is_empty(), "padding ignored by {failures:?}"); + let target_after: *mut objc2::runtime::AnyObject = unsafe { msg_send![native_button, target] }; + assert_eq!(target_after, target); + assert_eq!(native_button.action(), action); + drag_drop::check(mtm); + println!("PASS native widget padding"); + + fn ink_bounds(view: &NSView, size: CGSize, name: &str) -> (f64, f64, f64, f64) { + use objc2_app_kit::NSBitmapImageFileType; + use objc2_foundation::NSDictionary; + view.setFrameSize(CGSize::new(size.width.ceil(), size.height.ceil())); + let rect = view.bounds(); + let bitmap = view.bitmapImageRepForCachingDisplayInRect(rect).unwrap(); + view.cacheDisplayInRect_toBitmapImageRep(rect, &bitmap); + if let Ok(dir) = std::env::var("PERRY_PADDING_SNAPSHOTS") { + std::fs::create_dir_all(&dir).unwrap(); + let png = unsafe { + bitmap.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + } + .unwrap(); + std::fs::write( + std::path::Path::new(&dir).join(format!("{name}.png")), + png.to_vec(), + ) + .unwrap(); + } + let mut bounds = (isize::MAX, isize::MAX, -1, -1); + for y in 0..bitmap.pixelsHigh() { + for x in 0..bitmap.pixelsWide() { + if bitmap.colorAtX_y(x, y).unwrap().alphaComponent() > 0.1 { + bounds.0 = bounds.0.min(x); + bounds.1 = bounds.1.min(y); + bounds.2 = bounds.2.max(x); + bounds.3 = bounds.3.max(y); + } + } + } + assert!(bounds.2 >= 0, "{name}: rendered content must be visible"); + let scale_x = bitmap.pixelsWide() as f64 / rect.size.width; + let scale_y = bitmap.pixelsHigh() as f64 / rect.size.height; + ( + bounds.0 as f64 / scale_x, + bounds.1 as f64 / scale_y, + bounds.2 as f64 / scale_x, + bounds.3 as f64 / scale_y, + ) + } +} + +#[cfg(not(target_os = "macos"))] +fn main() {} +#[cfg(target_os = "macos")] +#[path = "native_widget_padding/drag_drop.rs"] +mod drag_drop; diff --git a/crates/perry-ui-macos/tests/native_widget_padding/drag_drop.rs b/crates/perry-ui-macos/tests/native_widget_padding/drag_drop.rs new file mode 100644 index 0000000000..168196e74f --- /dev/null +++ b/crates/perry-ui-macos/tests/native_widget_padding/drag_drop.rs @@ -0,0 +1,67 @@ +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, ClassBuilder, Sel}; +use objc2::{msg_send, sel, ClassType}; +use objc2_app_kit::{NSButton, NSView}; +use objc2_foundation::{MainThreadMarker, NSString}; +use perry_ui_macos::{drag_drop, widgets}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CLICKS: AtomicUsize = AtomicUsize::new(0); + +extern "C-unwind" fn clicked(_: *mut AnyObject, _: Sel, _: *mut AnyObject) { + CLICKS.fetch_add(1, Ordering::Relaxed); +} + +pub(super) fn check(mtm: MainThreadMarker) { + unsafe { + // An observable native mouseDown endpoint, without opening a window + // or depending on a hardware event loop. The drop-only path must + // forward here, through either ordering of the two behaviors. + let mut builder = ClassBuilder::new(c"PaddingTestButton", NSButton::class()).unwrap(); + builder.add_method( + sel!(mouseDown:), + clicked as extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject), + ); + let class = builder.register(); + for padding_first in [true, false] { + let button = NSButton::buttonWithTitle_target_action( + &NSString::from_str("Drop"), + None, + None, + mtm, + ); + AnyObject::set_class(&button, class); + let view: Retained = Retained::cast_unchecked(button); + let handle = widgets::register_widget(view.clone()); + let before = view.intrinsicContentSize(); + if padding_first { + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + } + drag_drop::perry_ui_widget_on_drop(handle, 0.0); + if !padding_first { + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + } + let padded = view.intrinsicContentSize(); + assert_eq!(padded.width, before.width + 16.0); + assert_eq!(padded.height, before.height + 10.0); + let count = CLICKS.load(Ordering::Relaxed); + let _: () = msg_send![&*view, mouseDown: std::ptr::null::()]; + assert_eq!( + CLICKS.load(Ordering::Relaxed), + count + 1, + "padding_first={padding_first}" + ); + + let class_before = view.class(); + drag_drop::perry_ui_widget_on_drop(handle, 0.0); + widgets::set_edge_insets(handle, 3.0, 5.0, 7.0, 11.0); + assert_eq!( + view.class(), + class_before, + "repeated setters must not nest subclasses" + ); + let _: () = msg_send![&*view, mouseDown: std::ptr::null::()]; + assert_eq!(CLICKS.load(Ordering::Relaxed), count + 2); + } + } +} diff --git a/docs/src/ui/styling.md b/docs/src/ui/styling.md index a6adb98a95..ff9d46f2a2 100644 --- a/docs/src/ui/styling.md +++ b/docs/src/ui/styling.md @@ -160,6 +160,15 @@ Use `setPadding(widget, value)` for uniform padding, or The old `widgetSetEdgeInsets` name is deprecated and remains an alias during the deprecation window. +On macOS, padding applies to stacks, buttons (including borderless buttons), +`Text` and `AttributedText` labels, text fields, text areas, and scroll views. +Button and label padding adds to their natural size and moves the content +inside the existing background or border. Setting padding again replaces the +previous insets; setting it to zero restores the natural size. + +For other macOS controls, place the control inside a padded `VStack` or `HStack`. +Development builds print a diagnostic when a native view has no padding support. + ### Sizing ```typescript