diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md
new file mode 100644
index 0000000000..a3795ef4d0
--- /dev/null
+++ b/changelog.d/11665-macos-textfield-baseline.md
@@ -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 `` 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.
diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml
index 389689d2b6..e2c56985e3 100644
--- a/crates/perry-ui-macos/Cargo.toml
+++ b/crates/perry-ui-macos/Cargo.toml
@@ -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"
diff --git a/crates/perry-ui-macos/src/srgb.rs b/crates/perry-ui-macos/src/srgb.rs
index 9e31b8696a..94d2c54c62 100644
--- a/crates/perry-ui-macos/src/srgb.rs
+++ b/crates/perry-ui-macos/src/srgb.rs
@@ -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" {
diff --git a/crates/perry-ui-macos/src/widgets/attributed_text.rs b/crates/perry-ui-macos/src/widgets/attributed_text.rs
index b3504c1b02..c0e1e95db2 100644
--- a/crates/perry-ui-macos/src/widgets/attributed_text.rs
+++ b/crates/perry-ui-macos/src/widgets/attributed_text.rs
@@ -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
diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs
index f53179f255..bb9c395764 100644
--- a/crates/perry-ui-macos/src/widgets/padding.rs
+++ b/crates/perry-ui-macos/src/widgets/padding.rs
@@ -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;
@@ -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, string: &NSString) -> Retained {
+ 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:))]
@@ -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, string: &NSString) -> Retained {
+ 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:))]
@@ -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 = 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 = 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.
diff --git a/crates/perry-ui-macos/src/widgets/securefield.rs b/crates/perry-ui-macos/src/widgets/securefield.rs
index ad00e42bef..0a3184f40a 100644
--- a/crates/perry-ui-macos/src/widgets/securefield.rs
+++ b/crates/perry-ui-macos/src/widgets/securefield.rs
@@ -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;
@@ -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 {
+ let field: Retained =
+ 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;
@@ -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 = 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::(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 = Retained::cast_unchecked(text_field);
let handle = super::register_widget(view);
diff --git a/crates/perry-ui-macos/src/widgets/text.rs b/crates/perry-ui-macos/src/widgets/text.rs
index 3ffb0e318c..2e1bc863f4 100644
--- a/crates/perry-ui-macos/src/widgets/text.rs
+++ b/crates/perry-ui-macos/src/widgets/text.rs
@@ -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;
@@ -125,6 +127,28 @@ 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 {
+ let label: Retained =
+ 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) };
@@ -132,8 +156,7 @@ pub fn create(text_ptr: *const u8) -> i64 {
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.
diff --git a/crates/perry-ui-macos/src/widgets/textfield.rs b/crates/perry-ui-macos/src/widgets/textfield.rs
index f7cba04fd4..bb33627f9b 100644
--- a/crates/perry-ui-macos/src/widgets/textfield.rs
+++ b/crates/perry-ui-macos/src/widgets/textfield.rs
@@ -1,9 +1,9 @@
use crate::ffi::{js_gc_pin_user_ptr, js_string_from_bytes};
use crate::srgb;
use objc2::rc::Retained;
-use objc2::runtime::{AnyObject, Sel};
-use objc2::{define_class, msg_send, AnyThread, DefinedClass};
-use objc2_app_kit::{NSLineBreakMode, NSTextField, NSView};
+use objc2::runtime::{AnyClass, AnyObject, Sel};
+use objc2::{define_class, msg_send, AnyThread, ClassType, DefinedClass, Message};
+use objc2_app_kit::{NSTextField, NSTextView, NSView};
use objc2_foundation::{
MainThreadMarker, NSNotification, NSNotificationCenter, NSObject, NSRange, NSRunLoop, NSString,
};
@@ -202,6 +202,59 @@ impl PerryTextFieldSubmitObserver {
}
}
+define_class!(
+ #[unsafe(super(NSTextField))]
+ #[name = "PerryTextField"]
+ pub struct PerryTextField;
+
+ impl PerryTextField {
+ #[unsafe(method(cellClass))]
+ fn cell_class() -> &'static AnyClass {
+ super::padding::PerryInsetTextFieldCell::class()
+ }
+
+ #[unsafe(method(setStringValue:))]
+ fn set_string_value(&self, value: &NSString) {
+ let value = 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(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 {
+ is_line_break_command(command)
+ || unsafe { msg_send![super(self), textView: editor, doCommandBySelector: command] }
+ }
+ }
+);
+
+/// An editable one-line text field, as `textFieldWithString:` builds it, with
+/// an inset cell so `set_edge_insets` can pad it.
+pub(crate) fn text_field(string: &NSString, _mtm: MainThreadMarker) -> Retained {
+ let field: Retained =
+ unsafe { msg_send![PerryTextField::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;
@@ -214,25 +267,9 @@ pub fn create(placeholder_ptr: *const u8, on_change: f64) -> i64 {
let ns_placeholder = NSString::from_str(&placeholder);
unsafe {
- let text_field = NSTextField::textFieldWithString(&NSString::from_str(""), mtm);
- super::padding::install_text_field_cell(&text_field, mtm);
+ let text_field = text_field(&NSString::from_str(""), mtm);
text_field.setPlaceholderString(Some(&ns_placeholder));
- // Make it editable
- text_field.setEditable(true);
- text_field.setBezeled(true);
-
- // Single-line, to match TextField on every other backend; TextArea is
- // the multiline widget. textFieldWithString: hands back a cell that
- // wraps and grows tall, so a fixed-width field must be told to keep one
- // line and scroll horizontally instead.
- if let Some(cell) = text_field.cell() {
- cell.setUsesSingleLineMode(true);
- cell.setScrollable(true);
- cell.setWraps(false);
- cell.setLineBreakMode(NSLineBreakMode::ByClipping);
- }
-
let view: Retained = Retained::cast_unchecked(text_field);
let handle = super::register_widget(view);
@@ -550,12 +587,12 @@ pub fn set_borderless(handle: i64, borderless: f64) {
/// Set the background color of the text field.
pub fn set_background_color(handle: i64, r: f64, g: f64, b: f64, a: f64) {
if let Some(view) = super::get_widget(handle) {
- unsafe {
- let tf: &NSTextField = &*(Retained::as_ptr(&view) as *const NSTextField);
- tf.setDrawsBackground(true);
- let color = srgb::ns_color(r, g, b, a);
- tf.setBackgroundColor(Some(&color));
- }
+ // If the cell drew the background, it would fill only the text area
+ // inside the padding. The layer fills the whole field, as a CSS
+ // background does.
+ let tf: &NSTextField = unsafe { &*(Retained::as_ptr(&view) as *const NSTextField) };
+ tf.setDrawsBackground(false);
+ super::set_background_color(handle, r, g, b, a);
}
}
@@ -595,3 +632,62 @@ pub fn set_text_color(handle: i64, r: f64, g: f64, b: f64, a: f64) {
}
}
}
+
+/// The characters that a web `` treats as a line break.
+const LINE_BREAKS: [char; 2] = ['\r', '\n'];
+
+/// Removes every line break from `value`, as a web `` does when code
+/// sets its value. A TextField and a SecureField each hold one line.
+pub(crate) fn strip_line_breaks(value: &NSString) -> Retained {
+ let text = value.to_string();
+ if text.contains(LINE_BREAKS) {
+ NSString::from_str(&text.replace(LINE_BREAKS, ""))
+ } else {
+ value.retain()
+ }
+}
+
+/// Replaces each line break in typed, pasted or dropped text with one space,
+/// as a web `` does. `None` when `text` has no line break.
+pub(crate) fn replace_line_breaks_with_spaces(text: &NSString) -> Option> {
+ let text = text.to_string();
+ text.contains(LINE_BREAKS)
+ .then(|| NSString::from_str(&text.replace("\r\n", " ").replace(LINE_BREAKS, " ")))
+}
+
+/// The field editor commands that would insert a line break. A web ``
+/// ignores Option-Return and Control-Return, so a TextField does too. Return
+/// sends `insertNewline:`, which is not in this list, so Return still submits.
+pub(crate) fn is_line_break_command(command: Sel) -> bool {
+ [
+ objc2::sel!(insertNewlineIgnoringFieldEditor:),
+ objc2::sel!(insertLineBreak:),
+ objc2::sel!(insertParagraphSeparator:),
+ ]
+ .contains(&command)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn line_breaks_match_a_web_input() {
+ for (text, set_in_code, entered) in [
+ ("one line", "one line", None),
+ ("a\nb\r\nc\rd", "abcd", Some("a b c d")),
+ ("trailing\n", "trailing", Some("trailing ")),
+ ("a\u{2028}b\u{2029}c\td", "a\u{2028}b\u{2029}c\td", None),
+ ] {
+ let text = NSString::from_str(text);
+ assert_eq!(
+ (
+ strip_line_breaks(&text).to_string(),
+ replace_line_breaks_with_spaces(&text).map(|spaced| spaced.to_string())
+ ),
+ (set_in_code.to_string(), entered.map(str::to_string)),
+ "{text:?}"
+ );
+ }
+ }
+}
diff --git a/crates/perry-ui-macos/tests/native_dynamic_colors.rs b/crates/perry-ui-macos/tests/native_dynamic_colors.rs
index 99901f7a3e..9b3dfa32ff 100644
--- a/crates/perry-ui-macos/tests/native_dynamic_colors.rs
+++ b/crates/perry-ui-macos/tests/native_dynamic_colors.rs
@@ -104,7 +104,7 @@ fn main() {
appearance.performAsCurrentDrawingAppearance(&RcBlock::new(|| unsafe {
let layer: *mut objc2::runtime::AnyObject = msg_send![view, layer];
for border in [false, true] {
- let cg: *const std::ffi::c_void = if border {
+ let cg: *const perry_ui_macos::srgb::CGColor = if border {
msg_send![layer, borderColor]
} else {
msg_send![layer, backgroundColor]
diff --git a/crates/perry-ui-macos/tests/native_text_color.rs b/crates/perry-ui-macos/tests/native_text_color.rs
index f0a539d16e..79f9b42ed1 100644
--- a/crates/perry-ui-macos/tests/native_text_color.rs
+++ b/crates/perry-ui-macos/tests/native_text_color.rs
@@ -1,7 +1,6 @@
-// #10856 — textSetColor must color a Text() label. install_label_cell used to
-// restore the text with setAttributedStringValue:, baking the default
-// labelColor into the field; an NSTextField holding an attributed string
-// ignores setTextColor:, so the color was silently overridden. This drives the
+// #10856 — textSetColor must color a Text() label. An NSTextField holding an
+// attributed string ignores setTextColor:, because the string's own color
+// attribute wins, so the label must hold a plain string. This drives the
// real create + set_color path (the same entry points textSetColor lowers to)
// and asserts the label's rendered foreground color is the requested red.
//
diff --git a/crates/perry-ui-macos/tests/native_textfield_cell.rs b/crates/perry-ui-macos/tests/native_textfield_cell.rs
new file mode 100644
index 0000000000..b6bf1c7316
--- /dev/null
+++ b/crates/perry-ui-macos/tests/native_textfield_cell.rs
@@ -0,0 +1,296 @@
+// TextField and SecureField draw through Perry's inset cell. The cell must
+// keep the behaviour of the cell that `textFieldWithString:` builds:
+// - the idle text sits where the field editor draws it, for every font and
+// control size (#11661);
+// - a long value stays on one line (#10155);
+// - `setPadding` moves the text by the top and left insets (#9954);
+// - `textfieldSetBackgroundColor` fills the whole field, padding included, as
+// a CSS background does.
+//
+// AppKit must run on the process main thread, so this test has no Rust harness.
+#[cfg(target_os = "macos")]
+fn main() {
+ use objc2::rc::Retained;
+ use objc2::{ClassType, MainThreadOnly};
+ use objc2_app_kit::{
+ NSAppearance, NSAppearanceCustomization, NSApplication, NSBackingStoreType, NSColor,
+ NSControlSize, NSFont, NSTextField, NSTextView, NSView, NSWindow, NSWindowStyleMask,
+ };
+ use objc2_core_foundation::{CGPoint, CGRect, CGSize};
+ use objc2_foundation::{MainThreadMarker, NSRange, NSString};
+ use perry_ui_macos::widgets;
+
+ /// How the test styles the field's border.
+ #[derive(Debug, Clone, Copy)]
+ enum Border {
+ /// `textfieldSetBorderless(f, 1)`.
+ Borderless,
+ /// The bezel that `textFieldWithString:` builds.
+ Default,
+ /// `textfieldSetBorderless(f, 0)`.
+ Bordered,
+ }
+
+ struct Case {
+ secure: bool,
+ border: Border,
+ family: &'static str,
+ size: f64,
+ control_size: NSControlSize,
+ text: &'static str,
+ padding: Option<(f64, f64)>,
+ }
+
+ /// The ink bounds of the idle field and of the same field while editing.
+ struct Drawn {
+ idle: Ink,
+ editing: Ink,
+ line_height: f64,
+ }
+
+ #[derive(Debug, Clone, Copy)]
+ struct Ink {
+ top: f64,
+ left: f64,
+ bottom: f64,
+ /// The size of one bitmap pixel, in points.
+ pixel: f64,
+ }
+
+ if std::env::args().any(|arg| arg == "--list") {
+ println!("native_textfield_cell: test");
+ return;
+ }
+ let mtm = MainThreadMarker::new().expect("TextField cell test runs on the main thread");
+ let _app = NSApplication::sharedApplication(mtm);
+
+ let long = "Hxg the quick brown fox jumps over the lazy dog, then jumps over it again";
+ let mut cases = Vec::new();
+ for secure in [false, true] {
+ for border in [Border::Borderless, Border::Default, Border::Bordered] {
+ for (family, size, control_size) in [
+ ("Helvetica", 16.0, NSControlSize::Regular),
+ ("Helvetica", 28.0, NSControlSize::Regular),
+ ("Helvetica", 10.0, NSControlSize::Regular),
+ ("Menlo", 16.0, NSControlSize::Small),
+ ("Times", 20.0, NSControlSize::Regular),
+ ] {
+ cases.push(Case {
+ secure,
+ border,
+ family,
+ size,
+ control_size,
+ text: "Hxg",
+ padding: None,
+ });
+ }
+ cases.push(Case {
+ secure,
+ border,
+ family: "Helvetica",
+ size: 16.0,
+ control_size: NSControlSize::Regular,
+ text: long,
+ padding: None,
+ });
+ cases.push(Case {
+ secure,
+ border,
+ family: "Helvetica",
+ size: 16.0,
+ control_size: NSControlSize::Regular,
+ text: "Hxg",
+ padding: Some((6.0, 10.0)),
+ });
+ }
+ }
+
+ let mut failures = Vec::new();
+ for case in &cases {
+ let name = format!(
+ "{} {:?} {} {} {:?} {:?}{}",
+ if case.secure {
+ "SecureField"
+ } else {
+ "TextField"
+ },
+ case.border,
+ case.family,
+ case.size,
+ case.control_size,
+ case.text,
+ case.padding
+ .map_or(String::new(), |p| format!(" padded {p:?}")),
+ );
+ let drawn = draw(case, mtm);
+ println!("{name}: idle {:?}, editing {:?}", drawn.idle, drawn.editing);
+ let tolerance = drawn.idle.pixel + 0.01;
+
+ // The field editor scrolls a value wider than the field 2pt left, as
+ // it does in a stock NSTextField, so only a value that fits keeps its
+ // left edge.
+ let fits = case.text != long;
+ if (drawn.idle.top - drawn.editing.top).abs() > tolerance
+ || fits && (drawn.idle.left - drawn.editing.left).abs() > tolerance
+ {
+ failures.push(format!("{name}: text moves when editing starts"));
+ }
+ for (state, ink) in [("idle", drawn.idle), ("editing", drawn.editing)] {
+ if ink.bottom - ink.top > drawn.line_height {
+ failures.push(format!("{name}: {state} text wraps past one line"));
+ }
+ }
+ if let Some((top, left)) = case.padding {
+ let unpadded = draw(
+ &Case {
+ padding: None,
+ ..*case
+ },
+ mtm,
+ );
+ if (drawn.idle.top - unpadded.idle.top - top).abs() > tolerance
+ || (drawn.idle.left - unpadded.idle.left - left).abs() > tolerance
+ {
+ failures.push(format!(
+ "{name}: padding moved the text by ({}, {})",
+ drawn.idle.top - unpadded.idle.top,
+ drawn.idle.left - unpadded.idle.left
+ ));
+ }
+ }
+ }
+ for secure in [false, true] {
+ let empty = perry_runtime::string::js_string_from_bytes(b"".as_ptr(), 0);
+ let handle = if secure {
+ widgets::securefield::create(empty.cast(), 0.0)
+ } else {
+ widgets::textfield::create(empty.cast(), 0.0)
+ };
+ widgets::set_edge_insets(handle, 6.0, 10.0, 0.0, 0.0);
+ widgets::textfield::set_background_color(handle, 1.0, 1.0, 0.0, 1.0);
+ let view = widgets::get_widget(handle).unwrap();
+ let field = unsafe { &*(Retained::as_ptr(&view) as *const NSTextField) };
+ // The cell's background would fill only the text area inside the
+ // padding; the layer's fills the whole field.
+ let layer_color: *const perry_ui_macos::srgb::CGColor = unsafe {
+ let layer: *mut objc2::runtime::AnyObject = objc2::msg_send![field, layer];
+ assert!(!layer.is_null(), "the field is layer-backed");
+ objc2::msg_send![layer, backgroundColor]
+ };
+ let color: Option> =
+ unsafe { objc2::msg_send![NSColor::class(), colorWithCGColor: layer_color] };
+ let rgba = color
+ .and_then(|c| c.colorUsingColorSpace(&objc2_app_kit::NSColorSpace::sRGBColorSpace()))
+ .map(|c| {
+ (
+ c.redComponent(),
+ c.greenComponent(),
+ c.blueComponent(),
+ c.alphaComponent(),
+ )
+ });
+ if field.drawsBackground() || rgba != Some((1.0, 1.0, 0.0, 1.0)) {
+ failures.push(format!(
+ "{}: background drawsBackground={} layer={rgba:?}",
+ if secure { "SecureField" } else { "TextField" },
+ field.drawsBackground()
+ ));
+ }
+ }
+
+ assert!(failures.is_empty(), "{failures:#?}");
+ println!("PASS native TextField cell");
+
+ fn draw(case: &Case, mtm: MainThreadMarker) -> Drawn {
+ let empty = perry_runtime::string::js_string_from_bytes(b"".as_ptr(), 0);
+ let handle = if case.secure {
+ widgets::securefield::create(empty.cast(), 0.0)
+ } else {
+ widgets::textfield::create(empty.cast(), 0.0)
+ };
+ match case.border {
+ Border::Borderless => widgets::textfield::set_borderless(handle, 1.0),
+ Border::Default => {}
+ Border::Bordered => widgets::textfield::set_borderless(handle, 0.0),
+ }
+ widgets::textfield::set_text_str(handle, case.text);
+ if let Some((top, left)) = case.padding {
+ widgets::set_edge_insets(handle, top, left, 0.0, 0.0);
+ }
+ let view = widgets::get_widget(handle).unwrap();
+ let field = unsafe { &*(Retained::as_ptr(&view) as *const NSTextField) };
+ let font = NSFont::fontWithName_size(&NSString::from_str(case.family), case.size)
+ .expect("the font is installed");
+ field.setFont(Some(&font));
+ field.setControlSize(case.control_size);
+ field.setTextColor(Some(&NSColor::blackColor()));
+ field.setDrawsBackground(true);
+ field.setBackgroundColor(Some(&NSColor::whiteColor()));
+ let field_frame = CGRect::new(CGPoint::new(20.0, 20.0), CGSize::new(200.0, 70.0));
+ field.setFrame(field_frame);
+
+ let window = unsafe {
+ NSWindow::initWithContentRect_styleMask_backing_defer(
+ NSWindow::alloc(mtm),
+ CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(240.0, 110.0)),
+ NSWindowStyleMask::Titled,
+ NSBackingStoreType::Buffered,
+ false,
+ )
+ };
+ unsafe { window.setReleasedWhenClosed(false) };
+ // A bezel draws its own background and ignores the white one set
+ // above, so in dark mode it would read as ink across the whole field.
+ let light = NSAppearance::appearanceNamed(&NSString::from_str("NSAppearanceNameAqua"));
+ window.setAppearance(light.as_deref());
+ let content = window.contentView().unwrap();
+ content.addSubview(&view);
+
+ let idle = ink(&content, field_frame);
+ window.makeFirstResponder(Some(field));
+ let editor = field
+ .currentEditor()
+ .expect("the focused field has an editor");
+ let editor = unsafe { &*(Retained::as_ptr(&editor) as *const NSTextView) };
+ editor.setSelectedRange(NSRange::new(0, 0));
+ // A black caret would count as ink and move the editing bounds.
+ editor.setInsertionPointColor(Some(&NSColor::whiteColor()));
+ let editing = ink(&content, field_frame);
+ window.close();
+
+ Drawn {
+ idle,
+ editing,
+ line_height: font.ascender() - font.descender() + font.leading() + 2.0,
+ }
+ }
+
+ /// The bounds of the dark ink inside `rect`, in points from its top-left.
+ fn ink(view: &NSView, rect: CGRect) -> Ink {
+ let bitmap = view.bitmapImageRepForCachingDisplayInRect(rect).unwrap();
+ view.cacheDisplayInRect_toBitmapImageRep(rect, &bitmap);
+ let scale = bitmap.pixelsHigh() as f64 / rect.size.height;
+ let mut bounds = (isize::MAX, isize::MAX, -1);
+ for y in 0..bitmap.pixelsHigh() {
+ for x in 0..bitmap.pixelsWide() {
+ let color = bitmap.colorAtX_y(x, y).unwrap();
+ if color.alphaComponent() > 0.5 && color.brightnessComponent() < 0.5 {
+ bounds.0 = bounds.0.min(y);
+ bounds.1 = bounds.1.min(x);
+ bounds.2 = bounds.2.max(y);
+ }
+ }
+ }
+ assert!(bounds.2 >= 0, "the field draws no text");
+ Ink {
+ top: bounds.0 as f64 / scale,
+ left: bounds.1 as f64 / scale,
+ bottom: bounds.2 as f64 / scale,
+ pixel: 1.0 / scale,
+ }
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn main() {}
diff --git a/crates/perry-ui-macos/tests/native_textfield_newlines.rs b/crates/perry-ui-macos/tests/native_textfield_newlines.rs
new file mode 100644
index 0000000000..0b5fedba3a
--- /dev/null
+++ b/crates/perry-ui-macos/tests/native_textfield_newlines.rs
@@ -0,0 +1,216 @@
+// TextField and SecureField hold one line, as a web `` does:
+// - a value set in code drops its line breaks;
+// - typed, pasted or dropped text turns each line break into one space;
+// - Option-Return and Control-Return insert nothing;
+// - Return still submits.
+// A Text label is not an input, so it keeps the line breaks in its value.
+//
+// AppKit must run on the process main thread, so this test has no Rust harness.
+#[cfg(target_os = "macos")]
+unsafe extern "C" {
+ fn js_nanbox_pointer(ptr: i64) -> f64;
+}
+
+#[cfg(target_os = "macos")]
+fn main() {
+ use objc2::rc::Retained;
+ use objc2::runtime::{AnyObject, Sel};
+ use objc2::{msg_send, sel, MainThreadOnly};
+ use objc2_app_kit::{
+ NSApplication, NSBackingStoreType, NSPasteboard, NSTextField, NSWindow, NSWindowStyleMask,
+ };
+ use objc2_core_foundation::{CGPoint, CGRect, CGSize};
+ use objc2_foundation::{MainThreadMarker, NSRange, NSString};
+ use perry_ui_macos::widgets;
+
+ /// How the case puts text into the field.
+ #[derive(Debug, Clone, Copy)]
+ enum Input {
+ SetInCode(&'static str),
+ Typed(&'static str),
+ Pasted(&'static str),
+ Dropped(&'static str),
+ Command(Sel),
+ }
+
+ /// What the field shows once the input is in.
+ #[derive(Debug, PartialEq)]
+ struct Outcome {
+ value: String,
+ submitted: bool,
+ height: f64,
+ }
+
+ static SUBMITS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
+
+ if std::env::args().any(|arg| arg == "--list") {
+ println!("native_textfield_newlines: test");
+ return;
+ }
+ let mtm = MainThreadMarker::new().expect("TextField newline test runs on the main thread");
+ let _app = NSApplication::sharedApplication(mtm);
+
+ let mut failures = Vec::new();
+ for secure in [false, true] {
+ let one_line = run(secure, Input::SetInCode("ab"), mtm).height;
+ // Each case: the input, the value the field ends with, and whether it
+ // submits.
+ let cases = [
+ (Input::SetInCode("a\nb\r\nc\rd"), "abcd", false),
+ (Input::Typed("a\nb"), "abca b", false),
+ (Input::Pasted("a\nb\r\nc"), "abca b c", false),
+ (Input::Dropped("a\nb"), "abca b", false),
+ (
+ Input::Command(sel!(insertNewlineIgnoringFieldEditor:)),
+ "abc",
+ false,
+ ),
+ (Input::Command(sel!(insertLineBreak:)), "abc", false),
+ (
+ Input::Command(sel!(insertParagraphSeparator:)),
+ "abc",
+ false,
+ ),
+ (Input::Command(sel!(insertNewline:)), "abc", true),
+ ];
+ for (input, value, submits) in cases {
+ let expected = Outcome {
+ value: value.to_string(),
+ // Perry exposes onSubmit on TextField only.
+ submitted: submits && !secure,
+ height: one_line,
+ };
+ let actual = run(secure, input, mtm);
+ let name = format!(
+ "{} {input:?}",
+ if secure { "SecureField" } else { "TextField" }
+ );
+ println!("{name}: {actual:?}");
+ if actual != expected {
+ failures.push(format!("{name}: expected {expected:?}, got {actual:?}"));
+ }
+ }
+ }
+ for text in ["a\nb", "a\r\nb\rc"] {
+ let js = |text: &str| {
+ perry_runtime::string::js_string_from_bytes(text.as_ptr(), text.len() as u32).cast()
+ };
+ let created = widgets::text::create(js(text));
+ let set = widgets::text::create(js(""));
+ widgets::text::set_text_str(set, text);
+ let actual = [created, set].map(|handle| {
+ let view = widgets::get_widget(handle).unwrap();
+ let label = unsafe { &*(Retained::as_ptr(&view) as *const NSTextField) };
+ label.stringValue().to_string()
+ });
+ println!("Text {text:?}: {actual:?}");
+ if actual != [text; 2] {
+ failures.push(format!("Text {text:?}: expected {text:?}, got {actual:?}"));
+ }
+ }
+ assert!(failures.is_empty(), "{failures:#?}");
+ println!("PASS native TextField newlines");
+
+ /// Puts `input` into a new field that holds "abc" with the caret at its
+ /// end, then reads back what the field holds.
+ fn run(secure: bool, input: Input, mtm: MainThreadMarker) -> Outcome {
+ // An edit calls onChange, so the field needs a real closure.
+ use perry_runtime::closure::{ClosureHeader, JsFunctionInfo, JsThis};
+ extern "C" fn on_change(_closure: *const ClosureHeader, _this: JsThis, _value: f64) -> f64 {
+ f64::from_bits(0x7FFC_0000_0000_0001)
+ }
+ extern "C" fn on_submit(_closure: *const ClosureHeader, _this: JsThis, _value: f64) -> f64 {
+ SUBMITS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ f64::from_bits(0x7FFC_0000_0000_0001)
+ }
+ let closure = |info: *const JsFunctionInfo| unsafe {
+ js_nanbox_pointer(perry_runtime::closure::js_closure_alloc(info, 0) as i64)
+ };
+ let on_change = closure(perry_runtime::fn_info!(on_change, 1));
+ let empty = perry_runtime::string::js_string_from_bytes(b"".as_ptr(), 0);
+ let handle = if secure {
+ widgets::securefield::create(empty.cast(), on_change)
+ } else {
+ widgets::textfield::create(empty.cast(), on_change)
+ };
+ if !secure {
+ widgets::textfield::set_on_submit(
+ handle,
+ closure(perry_runtime::fn_info!(on_submit, 1)),
+ );
+ }
+ let submits_before = SUBMITS.load(std::sync::atomic::Ordering::SeqCst);
+ let view = widgets::get_widget(handle).unwrap();
+ let field = unsafe { &*(Retained::as_ptr(&view) as *const NSTextField) };
+ let window = unsafe {
+ NSWindow::initWithContentRect_styleMask_backing_defer(
+ NSWindow::alloc(mtm),
+ CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(300.0, 80.0)),
+ NSWindowStyleMask::Titled,
+ NSBackingStoreType::Buffered,
+ false,
+ )
+ };
+ unsafe { window.setReleasedWhenClosed(false) };
+ window.contentView().unwrap().addSubview(&view);
+
+ if let Input::SetInCode(text) = input {
+ widgets::textfield::set_text_str(handle, text);
+ } else {
+ widgets::textfield::set_text_str(handle, "abc");
+ window.makeFirstResponder(Some(field));
+ let editor = field
+ .currentEditor()
+ .expect("the focused field has an editor");
+ let editor: &AnyObject = &editor;
+ unsafe {
+ let _: () = msg_send![editor, setSelectedRange: NSRange::new(3, 0)];
+ match input {
+ Input::Typed(text) => {
+ let _: () = msg_send![editor, insertText: &*NSString::from_str(text), replacementRange: NSRange::new(3, 0)];
+ }
+ Input::Pasted(text) | Input::Dropped(text) => {
+ let pasteboard =
+ NSPasteboard::pasteboardWithName(&NSString::from_str("perry-newlines"));
+ pasteboard.clearContents();
+ let string_type = NSString::from_str("public.utf8-plain-text");
+ pasteboard.setString_forType(&NSString::from_str(text), &string_type);
+ // A drop reads the dragging pasteboard by type; a paste
+ // reads the general one without.
+ let _: bool = if matches!(input, Input::Dropped(_)) {
+ msg_send![editor, readSelectionFromPasteboard: &*pasteboard, type: &*string_type]
+ } else {
+ msg_send![editor, readSelectionFromPasteboard: &*pasteboard]
+ };
+ }
+ Input::Command(command) => {
+ let _: () = msg_send![editor, doCommandBySelector: command];
+ }
+ Input::SetInCode(_) => unreachable!(),
+ }
+ }
+ }
+ let submitted = SUBMITS.load(std::sync::atomic::Ordering::SeqCst) != submits_before;
+ let value = match field.currentEditor() {
+ Some(editor) => editor.string().to_string(),
+ None => field.stringValue().to_string(),
+ };
+ // End editing so the field's own value and size reflect the input.
+ window.makeFirstResponder(None);
+ let outcome = Outcome {
+ value,
+ submitted,
+ height: field.intrinsicContentSize().height,
+ };
+ assert_eq!(
+ field.stringValue().to_string(),
+ outcome.value,
+ "the field keeps the editor's value once editing ends"
+ );
+ window.close();
+ outcome
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn main() {}
diff --git a/test-files/test_issue_11661_textfield_baseline.ts b/test-files/test_issue_11661_textfield_baseline.ts
new file mode 100644
index 0000000000..a6e3422ac6
--- /dev/null
+++ b/test-files/test_issue_11661_textfield_baseline.ts
@@ -0,0 +1,58 @@
+// Smoke test for issue #11661 and the issues that share its cause: macOS
+// TextField, SecureField and Text build Perry's inset cell, which must keep the
+// behaviour of the cell AppKit builds.
+// Run the built app and check each row:
+// - The first field opens focused, so the field editor draws it. Its text must
+// sit at the same height as the idle 16pt field below it (#11661).
+// - Clicking into any field must not move its text (#11661).
+// - The 28pt text must stay inside its yellow box (#11661).
+// - The long value must stay on one line and scroll (#10155).
+// - The padded fields, borderless and bezeled, and the padded label must inset
+// their text by 12 at the top and 24 at the left (#9954, #10159).
+// - The SecureField must show bullets on one line.
+// - The label must be red (#10856).
+import { App, SecureField, Text, TextField, VStack, setPadding, textSetColor, textSetFontFamily, textfieldSetBackgroundColor, textfieldSetBorderless, textfieldSetFontSize, textfieldSetString, textfieldSetTextColor, widgetSetBackgroundColor, widgetSetHeight } from "perry/ui"
+
+function style(f: any, size: number, height: number) {
+ textfieldSetBorderless(f, 1)
+ textfieldSetFontSize(f, size)
+ textSetFontFamily(f, "Helvetica")
+ textfieldSetBackgroundColor(f, 1, 1, 0.7, 1)
+ textfieldSetTextColor(f, 0, 0, 0, 1)
+ widgetSetHeight(f, height)
+}
+
+function field(text: string, size: number) {
+ const f = TextField("", () => {})
+ style(f, size, 40)
+ textfieldSetString(f, text)
+ return f
+}
+
+const padded = field("Hxg padded 16pt", 16)
+setPadding(padded, 12, 24, 0, 0)
+
+const bezeled = TextField("", () => {})
+textfieldSetString(bezeled, "Hxg padded bezel")
+setPadding(bezeled, 12, 24, 0, 0)
+
+const secure = SecureField("", () => {})
+style(secure, 16, 30)
+textfieldSetString(secure, "a long secret value that must stay on one line")
+
+const label = Text("Red label padded 12 top, 24 left")
+textSetColor(label, 1, 0, 0, 1)
+setPadding(label, 12, 24, 0, 0)
+
+const body = VStack(12, [
+ field("Hxg first 16pt", 16),
+ field("Hxg second 16pt", 16),
+ field("Hxg third 28pt", 28),
+ field("Hxg long value: the quick brown fox jumps over the lazy dog, then jumps over it again", 16),
+ padded,
+ bezeled,
+ secure,
+ label,
+])
+widgetSetBackgroundColor(body, 1, 1, 1, 1)
+App({ title: "issue 11661 TextField cell", width: 420, height: 480, windowState: "normal", body })
diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json
index fef71cae0e..97eba415c1 100644
--- a/test-parity/known_failures.json
+++ b/test-parity/known_failures.json
@@ -245,6 +245,15 @@
"linux"
]
},
+ "test_issue_11661_textfield_baseline": {
+ "issue": "8271",
+ "added": "2026-09-30",
+ "category": "ci-env",
+ "reason": "Compile fails on the Linux CI host: `perry/ui imported but libperry_ui_gtk4.a not found` — the parity job builds no perry-ui-gtk4 archive (same class as test_issue_10155_textfield_singleline and the rest of the libperry_ui_gtk4.a family, #8271). A macOS smoke fixture for #11661, run by hand.",
+ "platforms": [
+ "linux"
+ ]
+ },
"test_issue_1193_cheerio_chain": {
"issue": "8271",
"added": "2026-08-17",