Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/10234-macos-leaf-padding.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions crates/perry-ui-macos/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 13 additions & 2 deletions crates/perry-ui-macos/src/drag_drop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,24 @@ unsafe fn call_provider(cb: f64) -> Option<String> {
/// 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();
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/perry-ui-macos/src/widgets/attributed_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 52 additions & 14 deletions crates/perry-ui-macos/src/widgets/padding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
left: Cell<f64>,
Expand Down Expand Up @@ -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)
Expand All @@ -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];
}

Expand All @@ -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];
}

Expand All @@ -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)
Expand All @@ -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];
}

Expand All @@ -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];
}

Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand All @@ -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;
}
}

Expand All @@ -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)]
Expand All @@ -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);
Expand Down
129 changes: 129 additions & 0 deletions crates/perry-ui-macos/src/widgets/padding/button.rs
Original file line number Diff line number Diff line change
@@ -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<NSEdgeInsets>.
(&*(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);
}
}
1 change: 1 addition & 0 deletions crates/perry-ui-macos/src/widgets/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading