Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f1ef5be
fix(ui-macos): draw idle TextField text on its font's baseline (#11661)
steinybot Sep 29, 2026
38c56dc
test(ui-macos): add a smoke test for TextField baseline (#11661)
steinybot Sep 29, 2026
926fa3d
fix(ui-macos): drop the unused secure-cell draw override (#11661)
steinybot Sep 29, 2026
9dd83f8
changelog: add the fragment for the TextField baseline fix (#11665)
steinybot Sep 29, 2026
5d3e0ef
fix(ui-macos): build the inset cell through cellClass (#11661)
steinybot Sep 29, 2026
a76f708
test(ui-macos): describe the label-colour constraint without the remo…
steinybot Sep 29, 2026
79087f9
test(ui-macos): cover padding, one-line, secure and label colour in t…
steinybot Sep 29, 2026
0213b16
refactor(ui-macos): rename the field classes to PerryTextField and Pe…
steinybot Sep 29, 2026
b99d512
fix(ui-macos): pass layer colours as a typed CGColor pointer (#11661)
steinybot Sep 29, 2026
28f8ecb
fix(ui-macos): pad text fields as CSS padding does (#11661)
steinybot Sep 29, 2026
f353f8c
fix(ui-macos): keep TextField and SecureField to one line, as a web i…
steinybot Sep 30, 2026
981e31d
refactor(ui-macos): move the field classes next to their widgets (#11…
steinybot Sep 30, 2026
092d7d3
fix(ui-macos): keep line breaks in Text labels (#11661)
steinybot Sep 30, 2026
b5ae184
refactor(ui-macos): state unused alternatives as hypotheticals in com…
steinybot Sep 30, 2026
1353f99
test(ui-macos): build the newlines test's closures from JsFunctionInf…
steinybot Sep 30, 2026
1d3fd4d
refactor(ui-macos): name the line-break helpers for what they do (#11…
steinybot Sep 30, 2026
a6dbe7c
changelog: drop the CGColor note from the TextField fragment (#11665)
steinybot Sep 30, 2026
a743dd4
test(ui-macos): use generic text for the long TextField value (#11661)
steinybot Sep 30, 2026
6d834c8
style(ui-macos): rustfmt the newlines test's submit closure (#11661)
steinybot Sep 30, 2026
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
26 changes: 26 additions & 0 deletions changelog.d/11665-macos-textfield-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Fixed a macOS `TextField` whose text moved when editing started (#11661).
`TextField`, `SecureField`, `Text` and `AttributedText` replaced the cell that
AppKit builds with a new `PerryInsetTextFieldCell`, so that `setPadding` could
inset their text. The new cell lost the factory setup. `TextField` then set
its one-line properties again and turned on `usesSingleLineMode`. In that mode
AppKit draws idle text on the baseline of the system font for the control
size, so a custom font sat 2pt high at 16pt Helvetica and clipped from about
20pt. `PerryTextField` and `PerrySecureTextField` now override
`cellClass`, so `textFieldWithString:` and `labelWithString:` build the inset
cell with the factory setup. The cell swap, the property restore for labels,
and the one-line properties are gone. `SecureField` is now one line and
scrolls, as `TextField` does.

`TextField` and `SecureField` hold one line, as a web `<input>` does, without
single-line mode. A value set in code drops its line breaks. Typed, pasted and
dropped text turns each line break into one space. Option-Return and
Control-Return insert nothing, and Return still submits.

`setPadding` on a `TextField` or `SecureField` now works as CSS padding does
on the web. A padded field with a bezel or a border moved its text by the
padding when editing started, because AppKit applied the cell's
`drawingRectForBounds:` padding to the editing frame as well. The cell now pads
only the frames that AppKit hands it to draw and to edit, so the padding
applies once for every border style. `textfieldSetBackgroundColor` paints the
field's layer, so the background fills the padding too. The cell's own
background filled only the area inside the padding.
10 changes: 10 additions & 0 deletions crates/perry-ui-macos/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ name = "native_textfield_selection"
path = "tests/native_textfield_selection.rs"
harness = false

[[test]]
name = "native_textfield_cell"
path = "tests/native_textfield_cell.rs"
harness = false

[[test]]
name = "native_textfield_newlines"
path = "tests/native_textfield_newlines.rs"
harness = false

[[test]]
name = "native_text_spacing"
path = "tests/native_text_spacing.rs"
Expand Down
15 changes: 14 additions & 1 deletion crates/perry-ui-macos/src/srgb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,20 @@ use objc2_app_kit::NSColor;
use std::ffi::c_void;
use std::sync::OnceLock;

type CGColorRef = *mut c_void;
/// CoreGraphics' opaque colour. A typed pointer encodes as `^{CGColor=}`, the
/// type CALayer's colour properties declare. If the pointer were `c_void`, it
/// would encode as `^v`, and a debug build's message check would reject the send.
#[repr(C)]
pub struct CGColor {
_private: [u8; 0],
}

unsafe impl objc2::encode::RefEncode for CGColor {
const ENCODING_REF: objc2::encode::Encoding =
objc2::encode::Encoding::Pointer(&objc2::encode::Encoding::Struct("CGColor", &[]));
}

type CGColorRef = *mut CGColor;
type CGColorSpaceRef = *mut c_void;

extern "C" {
Expand Down
3 changes: 1 addition & 2 deletions crates/perry-ui-macos/src/widgets/attributed_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ use perry_ffi::copy_string_from_raw as str_from_header;
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);
let label = super::text::label(&empty, mtm);
unsafe {
let _: () = msg_send![&*label, setTranslatesAutoresizingMaskIntoConstraints: false];
// Enable wrapping by default — per-range styling is most useful
Expand Down
91 changes: 32 additions & 59 deletions crates/perry-ui-macos/src/widgets/padding.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
use objc2::rc::Retained;
use objc2::rc::{Allocated, Retained};
use objc2::runtime::{AnyClass, AnyObject};
use objc2::{define_class, msg_send, DefinedClass, MainThreadOnly};
use objc2_app_kit::{
NSColor, NSEvent, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, NSView,
};
use objc2::{define_class, msg_send, DefinedClass};
use objc2_app_kit::{NSEvent, NSSecureTextFieldCell, NSText, NSTextFieldCell, NSView};
use objc2_core_foundation::CGRect;
use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol};
use objc2_foundation::{NSEdgeInsets, NSObjectProtocol, NSString};
use std::cell::Cell;

mod button;
Expand Down Expand Up @@ -51,10 +49,20 @@ define_class!(
pub struct PerryInsetTextFieldCell;

impl PerryInsetTextFieldCell {
#[unsafe(method(drawingRectForBounds:))]
fn drawing_rect_for_bounds(&self, bounds: CGRect) -> CGRect {
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_id(initTextCell:))]
fn init_text_cell(this: Allocated<Self>, string: &NSString) -> Retained<Self> {
let this = this.set_ivars(PerryInsetCellIvars::new());
unsafe { msg_send![super(this), initTextCell: string] }
}

// Padding goes on the frames AppKit hands in to draw and to edit, not
// in drawingRectForBounds:. AppKit's editing path for a borderless
// cell never calls drawingRectForBounds:, so padding there would put
// the editing text somewhere other than the idle text.
#[unsafe(method(drawInteriorWithFrame:inView:))]
fn draw_interior(&self, frame: CGRect, view: &NSView) {
let frame = inset_rect(frame, self.ivars().get(), view.isFlipped());
unsafe { msg_send![super(self), drawInteriorWithFrame: frame, inView: view] }
}

#[unsafe(method(cellSizeForBounds:))]
Expand Down Expand Up @@ -107,10 +115,20 @@ define_class!(
pub struct PerryInsetSecureTextFieldCell;

impl PerryInsetSecureTextFieldCell {
#[unsafe(method(drawingRectForBounds:))]
fn drawing_rect_for_bounds(&self, bounds: CGRect) -> CGRect {
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_id(initTextCell:))]
fn init_text_cell(this: Allocated<Self>, string: &NSString) -> Retained<Self> {
let this = this.set_ivars(PerryInsetCellIvars::new());
unsafe { msg_send![super(this), initTextCell: string] }
}

// Padding goes on the frames AppKit hands in to draw and to edit, not
// in drawingRectForBounds:. AppKit's editing path for a borderless
// cell never calls drawingRectForBounds:, so padding there would put
// the editing text somewhere other than the idle text.
#[unsafe(method(drawInteriorWithFrame:inView:))]
fn draw_interior(&self, frame: CGRect, view: &NSView) {
let frame = inset_rect(frame, self.ivars().get(), view.isFlipped());
unsafe { msg_send![super(self), drawInteriorWithFrame: frame, inView: view] }
}

#[unsafe(method(cellSizeForBounds:))]
Expand Down Expand Up @@ -189,51 +207,6 @@ fn padded_size(
)
}

pub(crate) fn install_text_field_cell(field: &NSTextField, mtm: MainThreadMarker) {
let this = PerryInsetTextFieldCell::alloc(mtm).set_ivars(PerryInsetCellIvars::new());
let cell: Retained<PerryInsetTextFieldCell> = unsafe { msg_send![super(this), init] };
unsafe {
let _: () = msg_send![field, setCell: &*cell];
}
}

pub(crate) fn install_secure_text_field_cell(field: &NSTextField, mtm: MainThreadMarker) {
let this = PerryInsetSecureTextFieldCell::alloc(mtm).set_ivars(PerryInsetCellIvars::new());
let cell: Retained<PerryInsetSecureTextFieldCell> = unsafe { msg_send![super(this), init] };
unsafe {
let _: () = msg_send![field, setCell: &*cell];
}
}

/// 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) {
// Restore the text as a plain stringValue, never the factory label's
// attributedStringValue. An NSTextField holding an attributed string
// ignores setTextColor: — the string's baked-in color attribute wins — so
// an attributedStringValue here silently defeats textSetColor (#10856).
// The font is restored separately below, and labelColor is set explicitly
// to keep the label's default appearance.
let text = field.stringValue();
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.setStringValue(&text);
field.setTextColor(Some(&NSColor::labelColor()));
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.
/// Perry's labels and buttons include padding in their native sizing and
/// drawing paths, without replacing the widget or its target/action.
Expand Down
78 changes: 62 additions & 16 deletions crates/perry-ui-macos/src/widgets/securefield.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use crate::ffi::js_string_from_bytes;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Sel};
use objc2::{define_class, msg_send, AnyThread, DefinedClass, MainThreadOnly};
use objc2_app_kit::{NSSecureTextField, NSTextField, NSView};
use objc2::runtime::{AnyClass, AnyObject, Sel};
use objc2::{define_class, msg_send, AnyThread, ClassType, DefinedClass};
use objc2_app_kit::{NSSecureTextField, NSTextField, NSTextView, NSView};
use objc2_foundation::{
MainThreadMarker, NSNotification, NSNotificationCenter, NSObject, NSString,
MainThreadMarker, NSNotification, NSNotificationCenter, NSObject, NSRange, NSString,
};
use std::cell::RefCell;
use std::collections::HashMap;
Expand Down Expand Up @@ -86,6 +86,62 @@ impl PerrySecureFieldObserver {
}
}

define_class!(
#[unsafe(super(NSSecureTextField))]
#[name = "PerrySecureTextField"]
pub struct PerrySecureTextField;

impl PerrySecureTextField {
#[unsafe(method(cellClass))]
fn cell_class() -> &'static AnyClass {
super::padding::PerryInsetSecureTextFieldCell::class()
}

#[unsafe(method(setStringValue:))]
fn set_string_value(&self, value: &NSString) {
let value = super::textfield::strip_line_breaks(value);
unsafe { msg_send![super(self), setStringValue: &*value] }
}

#[unsafe(method(textView:shouldChangeTextInRange:replacementString:))]
fn should_change_text(
&self,
editor: &NSTextView,
range: NSRange,
replacement: Option<&NSString>,
) -> bool {
match replacement.and_then(super::textfield::replace_line_breaks_with_spaces) {
// Inserting the spaced text asks this method again, now with no
// line break, so the edit still passes through super.
Some(spaced) => {
let _: () = unsafe { msg_send![editor, insertText: &*spaced, replacementRange: range] };
false
}
None => unsafe {
msg_send![super(self), textView: editor, shouldChangeTextInRange: range, replacementString: replacement]
},
}
}

#[unsafe(method(textView:doCommandBySelector:))]
fn do_command(&self, editor: &NSTextView, command: Sel) -> bool {
super::textfield::is_line_break_command(command)
|| unsafe { msg_send![super(self), textView: editor, doCommandBySelector: command] }
}
}
);

/// A one-line secure text field, as `textFieldWithString:` builds it, with an
/// inset cell so `set_edge_insets` can pad it.
pub(crate) fn secure_text_field(
string: &NSString,
_mtm: MainThreadMarker,
) -> Retained<NSSecureTextField> {
let field: Retained<PerrySecureTextField> =
unsafe { msg_send![PerrySecureTextField::class(), textFieldWithString: string] };
field.into_super()
}

/// Extract a &str from a *const StringHeader pointer.
use perry_ffi::copy_string_from_raw as str_from_header;

Expand All @@ -98,18 +154,8 @@ pub fn create(placeholder_ptr: *const u8, on_change: f64) -> i64 {
let ns_placeholder = NSString::from_str(&placeholder);

unsafe {
let text_field: Retained<NSSecureTextField> = msg_send![
NSSecureTextField::alloc(mtm), initWithFrame: objc2_core_foundation::CGRect::new(
objc2_core_foundation::CGPoint::new(0.0, 0.0),
objc2_core_foundation::CGSize::new(200.0, 22.0),
)
];

let tf_ref: &NSTextField = &Retained::cast_unchecked::<NSTextField>(text_field.clone());
super::padding::install_secure_text_field_cell(tf_ref, mtm);
tf_ref.setPlaceholderString(Some(&ns_placeholder));
tf_ref.setEditable(true);
tf_ref.setBezeled(true);
let text_field = secure_text_field(&NSString::from_str(""), mtm);
text_field.setPlaceholderString(Some(&ns_placeholder));

let view: Retained<NSView> = Retained::cast_unchecked(text_field);
let handle = super::register_widget(view);
Expand Down
27 changes: 25 additions & 2 deletions crates/perry-ui-macos/src/widgets/text.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::srgb;
use objc2::rc::Retained;
use objc2::runtime::AnyClass;
use objc2::{define_class, msg_send, ClassType};
use objc2_app_kit::{NSTextField, NSView};
use objc2_foundation::{MainThreadMarker, NSString};
use std::cell::RefCell;
Expand Down Expand Up @@ -125,15 +127,36 @@ pub fn set_line_height(handle: i64, multiple: f64) {
refresh_spacing(handle);
}

define_class!(
/// A label that shows the line breaks in its value.
#[unsafe(super(NSTextField))]
#[name = "PerryLabel"]
pub struct PerryLabel;

impl PerryLabel {
#[unsafe(method(cellClass))]
fn cell_class() -> &'static AnyClass {
super::padding::PerryInsetTextFieldCell::class()
}
}
);

/// A label, as `labelWithString:` builds it, with an inset cell so
/// `set_edge_insets` can pad it.
pub(crate) fn label(string: &NSString, _mtm: MainThreadMarker) -> Retained<NSTextField> {
let label: Retained<PerryLabel> =
unsafe { msg_send![PerryLabel::class(), labelWithString: string] };
label.into_super()
}

/// Create an NSTextField configured as a non-editable label.
pub fn create(text_ptr: *const u8) -> i64 {
let text = unsafe { str_from_header(text_ptr) };

let mtm = MainThreadMarker::new().expect("perry/ui must run on the main thread");
let ns_string = NSString::from_str(&text);

let label = NSTextField::labelWithString(&ns_string, mtm);
super::padding::install_label_cell(&label, mtm);
let label = label(&ns_string, 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