From f1ef5bed1842d16abf1c5f7e5d87997df059f4b2 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Tue, 29 Sep 2026 20:55:09 +1300 Subject: [PATCH 01/19] fix(ui-macos): draw idle TextField text on its font's baseline (#11661) In single-line mode, AppKit draws a cell's idle text on the baseline of the system font for the control size and ignores the cell's own font. The field editor uses the cell's font, so a TextField with a custom font moved when editing started. At 16pt Helvetica the text moved 2pt. From about 20pt, the idle text clipped at the top. The inset cells now draw their interior with single-line mode off. Single-line mode still governs the field editor, so newline input still becomes spaces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- crates/perry-ui-macos/Cargo.toml | 5 + crates/perry-ui-macos/src/widgets/padding.rs | 30 ++++- .../tests/native_textfield_baseline.rs | 114 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 crates/perry-ui-macos/tests/native_textfield_baseline.rs diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index 389689d2b6..35171c9b7e 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -112,6 +112,11 @@ name = "native_textfield_selection" path = "tests/native_textfield_selection.rs" harness = false +[[test]] +name = "native_textfield_baseline" +path = "tests/native_textfield_baseline.rs" +harness = false + [[test]] name = "native_text_spacing" path = "tests/native_text_spacing.rs" diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index f53179f255..77d0505e51 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -2,7 +2,7 @@ use objc2::rc::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, + NSCell, NSColor, NSEvent, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, NSView, }; use objc2_core_foundation::CGRect; use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol}; @@ -57,6 +57,13 @@ define_class!( unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } + #[unsafe(method(drawInteriorWithFrame:inView:))] + fn draw_interior(&self, frame: CGRect, view: &NSView) { + draw_on_font_baseline(self, || unsafe { + msg_send![super(self), drawInteriorWithFrame: frame, inView: view] + }); + } + #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); @@ -113,6 +120,13 @@ define_class!( unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } + #[unsafe(method(drawInteriorWithFrame:inView:))] + fn draw_interior(&self, frame: CGRect, view: &NSView) { + draw_on_font_baseline(self, || unsafe { + msg_send![super(self), drawInteriorWithFrame: frame, inView: view] + }); + } + #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); @@ -156,6 +170,20 @@ define_class!( } ); +/// Single-line mode draws idle text on the baseline of the control size's +/// system font, not the cell's own font; the field editor uses the cell's font, +/// so a custom font would jump when editing starts. Drawing with single-line +/// mode off puts the idle text where the field editor draws it, newlines +/// included. +fn draw_on_font_baseline(cell: &NSCell, draw: impl FnOnce()) { + if !cell.usesSingleLineMode() { + return draw(); + } + cell.setUsesSingleLineMode(false); + draw(); + cell.setUsesSingleLineMode(true); +} + 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. diff --git a/crates/perry-ui-macos/tests/native_textfield_baseline.rs b/crates/perry-ui-macos/tests/native_textfield_baseline.rs new file mode 100644 index 0000000000..866c410477 --- /dev/null +++ b/crates/perry-ui-macos/tests/native_textfield_baseline.rs @@ -0,0 +1,114 @@ +// A TextField must draw its text at the same height whether or not it is +// editing (#11661). The field editor draws on the font's own baseline, so the +// idle cell must too, for every font and control size. +// +// 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::MainThreadOnly; + use objc2_app_kit::{ + 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; + + if std::env::args().any(|arg| arg == "--list") { + println!("native_textfield_baseline: test"); + return; + } + let mtm = MainThreadMarker::new().expect("TextField baseline test runs on the main thread"); + let _app = NSApplication::sharedApplication(mtm); + let empty = perry_runtime::string::js_string_from_bytes(b"".as_ptr(), 0); + + let cases = [ + ("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), + ]; + let mut failures = Vec::new(); + for secure in [false, true] { + for (family, size, control_size) in cases { + let handle = if secure { + widgets::securefield::create(empty.cast(), 0.0) + } else { + widgets::textfield::create(empty.cast(), 0.0) + }; + widgets::textfield::set_borderless(handle, 1.0); + widgets::textfield::set_text_str(handle, "Hxg"); + 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(family), size) + .expect("the font is installed"); + field.setFont(Some(&font)); + field.setControlSize(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, 60.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, 100.0)), + NSWindowStyleMask::Titled, + NSBackingStoreType::Buffered, + false, + ) + }; + unsafe { window.setReleasedWhenClosed(false) }; + let content = window.contentView().unwrap(); + content.addSubview(&view); + + let idle = ink_top(&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(3, 0)); + editor.setInsertionPointColor(Some(&NSColor::whiteColor())); + content.display(); + let editing = ink_top(&content, field_frame); + window.close(); + + let name = format!( + "{} {family} {size} {control_size:?}", + if secure { "SecureField" } else { "TextField" } + ); + println!("{name}: idle top {idle}, editing top {editing}"); + if (idle - editing).abs() > 0.51 { + failures.push(format!("{name}: idle {idle} vs editing {editing}")); + } + } + } + assert!( + failures.is_empty(), + "text moves when editing starts: {failures:#?}" + ); + println!("PASS native TextField baseline"); + + // The top of the dark ink inside `rect`, in points below its top edge. + fn ink_top(view: &NSView, rect: CGRect) -> f64 { + let bitmap = view.bitmapImageRepForCachingDisplayInRect(rect).unwrap(); + view.cacheDisplayInRect_toBitmapImageRep(rect, &bitmap); + let scale = bitmap.pixelsHigh() as f64 / rect.size.height; + 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 { + return y as f64 / scale; + } + } + } + panic!("the field draws no text"); + } +} + +#[cfg(not(target_os = "macos"))] +fn main() {} From 38c56dc663287804dcddcff5fa59c58e15db888f Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Tue, 29 Sep 2026 21:11:25 +1300 Subject: [PATCH 02/19] test(ui-macos): add a smoke test for TextField baseline (#11661) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- .../test_issue_11661_textfield_baseline.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test-files/test_issue_11661_textfield_baseline.ts 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..62fff788ee --- /dev/null +++ b/test-files/test_issue_11661_textfield_baseline.ts @@ -0,0 +1,25 @@ +// Smoke test for issue #11661: on macOS a TextField must draw its text at the +// same height whether or not it is editing. +// Run the built app. The first field opens focused, so it draws through the +// field editor. The text in both fields must sit at the same height in its +// yellow box, and clicking into the second field must not move its text. +import { App, TextField, VStack, textSetFontFamily, textfieldSetBackgroundColor, textfieldSetBorderless, textfieldSetFontSize, textfieldSetString, textfieldSetTextColor, widgetSetBackgroundColor, widgetSetHeight } from "perry/ui" + +function field(text: string, size: number) { + const f = TextField("", () => {}) + textfieldSetBorderless(f, 1) + textfieldSetFontSize(f, size) + textSetFontFamily(f, "Helvetica") + textfieldSetBackgroundColor(f, 1, 1, 0.7, 1) + textfieldSetTextColor(f, 0, 0, 0, 1) + widgetSetHeight(f, 40) + textfieldSetString(f, text) + return f +} +const body = VStack(20, [ + field("Hxg first 16pt", 16), + field("Hxg second 16pt", 16), + field("Hxg third 28pt", 28), +]) +widgetSetBackgroundColor(body, 1, 1, 1, 1) +App({ title: "issue 11661 TextField baseline", width: 400, height: 240, windowState: "normal", body }) From 926fa3dbbdb381675f11819349ed3cfd87bc4e2a Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Tue, 29 Sep 2026 23:59:54 +1300 Subject: [PATCH 03/19] fix(ui-macos): drop the unused secure-cell draw override (#11661) The secure field never uses single-line mode, so its override never changed the drawing. The baseline test now also asserts that an idle draw leaves single-line mode as it was. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- crates/perry-ui-macos/src/widgets/padding.rs | 7 ------- crates/perry-ui-macos/tests/native_textfield_baseline.rs | 7 ++++++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index 77d0505e51..9864c113f4 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -120,13 +120,6 @@ define_class!( unsafe { msg_send![super(self), drawingRectForBounds: bounds] } } - #[unsafe(method(drawInteriorWithFrame:inView:))] - fn draw_interior(&self, frame: CGRect, view: &NSView) { - draw_on_font_baseline(self, || unsafe { - msg_send![super(self), drawInteriorWithFrame: frame, inView: view] - }); - } - #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); diff --git a/crates/perry-ui-macos/tests/native_textfield_baseline.rs b/crates/perry-ui-macos/tests/native_textfield_baseline.rs index 866c410477..fe099afb33 100644 --- a/crates/perry-ui-macos/tests/native_textfield_baseline.rs +++ b/crates/perry-ui-macos/tests/native_textfield_baseline.rs @@ -65,7 +65,13 @@ fn main() { let content = window.contentView().unwrap(); content.addSubview(&view); + let single_line = field.cell().unwrap().usesSingleLineMode(); let idle = ink_top(&content, field_frame); + assert_eq!( + field.cell().unwrap().usesSingleLineMode(), + single_line, + "drawing the idle field must leave its single-line mode as it was" + ); window.makeFirstResponder(Some(field)); let editor = field .currentEditor() @@ -73,7 +79,6 @@ fn main() { let editor = unsafe { &*(Retained::as_ptr(&editor) as *const NSTextView) }; editor.setSelectedRange(NSRange::new(3, 0)); editor.setInsertionPointColor(Some(&NSColor::whiteColor())); - content.display(); let editing = ink_top(&content, field_frame); window.close(); From 9dd83f89a572ff5848bb8c335f539ab7b97dc29f Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 00:03:53 +1300 Subject: [PATCH 04/19] changelog: add the fragment for the TextField baseline fix (#11665) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- changelog.d/11665-macos-textfield-baseline.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/11665-macos-textfield-baseline.md diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md new file mode 100644 index 0000000000..c5094bd0f1 --- /dev/null +++ b/changelog.d/11665-macos-textfield-baseline.md @@ -0,0 +1,8 @@ +Fixed a macOS `TextField` whose text moved when editing started (#11661). +In single-line mode, AppKit draws a cell's idle text on the baseline of the +system font for the control size and ignores the cell's own font. The field +editor uses the cell's font. So with a custom font, the idle text sat 2pt high +at 16pt Helvetica, 3pt low at 10pt, and clipped at the top from about 20pt. +`PerryInsetTextFieldCell` now draws its interior with single-line mode off, +so the idle text sits where the field editor draws it. Single-line mode still +governs the field editor, so newline input still becomes spaces. From 5d3e0ef900b8aa1e3196dd880c64f1184fe3a7e3 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 09:27:20 +1300 Subject: [PATCH 05/19] fix(ui-macos): build the inset cell through cellClass (#11661) Text fields and labels replaced the cell that AppKit builds, so that setPadding could inset their text. The new cell lost the factory setup. The fields then set it again by hand, and the label factories restored it one property at a time. Single-line mode, which one of those settings turned on, drew idle text on the baseline of the system font. PerryInsetTextField and PerryInsetSecureTextField now return the inset cells from cellClass. textFieldWithString: and labelWithString: then build the inset cell with the factory setup. This removes the cell swap, the label restore, the one-line settings, and the draw override that turned single-line mode off. SecureField now uses textFieldWithString:, so it is one line, like TextField. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- changelog.d/11665-macos-textfield-baseline.md | 19 +- crates/perry-ui-macos/Cargo.toml | 4 +- .../src/widgets/attributed_text.rs | 3 +- crates/perry-ui-macos/src/widgets/padding.rs | 125 +++++----- .../perry-ui-macos/src/widgets/securefield.rs | 18 +- crates/perry-ui-macos/src/widgets/text.rs | 3 +- .../perry-ui-macos/src/widgets/textfield.rs | 20 +- .../tests/native_textfield_baseline.rs | 119 --------- .../tests/native_textfield_cell.rs | 228 ++++++++++++++++++ 9 files changed, 312 insertions(+), 227 deletions(-) delete mode 100644 crates/perry-ui-macos/tests/native_textfield_baseline.rs create mode 100644 crates/perry-ui-macos/tests/native_textfield_cell.rs diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md index c5094bd0f1..d953c9cd56 100644 --- a/changelog.d/11665-macos-textfield-baseline.md +++ b/changelog.d/11665-macos-textfield-baseline.md @@ -1,8 +1,13 @@ Fixed a macOS `TextField` whose text moved when editing started (#11661). -In single-line mode, AppKit draws a cell's idle text on the baseline of the -system font for the control size and ignores the cell's own font. The field -editor uses the cell's font. So with a custom font, the idle text sat 2pt high -at 16pt Helvetica, 3pt low at 10pt, and clipped at the top from about 20pt. -`PerryInsetTextFieldCell` now draws its interior with single-line mode off, -so the idle text sits where the field editor draws it. Single-line mode still -governs the field editor, so newline input still becomes spaces. +`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. `PerryInsetTextField` and `PerryInsetSecureTextField` 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. Option-Return in a `TextField` inserts a +newline, as it does in a stock `NSTextField`. diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index 35171c9b7e..71b654f631 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -113,8 +113,8 @@ path = "tests/native_textfield_selection.rs" harness = false [[test]] -name = "native_textfield_baseline" -path = "tests/native_textfield_baseline.rs" +name = "native_textfield_cell" +path = "tests/native_textfield_cell.rs" harness = false [[test]] diff --git a/crates/perry-ui-macos/src/widgets/attributed_text.rs b/crates/perry-ui-macos/src/widgets/attributed_text.rs index b3504c1b02..d64346f873 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::padding::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 9864c113f4..5e324df929 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -1,11 +1,11 @@ -use objc2::rc::Retained; +use objc2::rc::{Allocated, Retained}; use objc2::runtime::{AnyClass, AnyObject}; -use objc2::{define_class, msg_send, DefinedClass, MainThreadOnly}; +use objc2::{define_class, msg_send, ClassType, DefinedClass}; use objc2_app_kit::{ - NSCell, NSColor, NSEvent, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, NSView, + NSEvent, NSSecureTextField, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, NSView, }; use objc2_core_foundation::CGRect; -use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol}; +use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol, NSString}; use std::cell::Cell; mod button; @@ -51,19 +51,18 @@ define_class!( pub struct PerryInsetTextFieldCell; impl PerryInsetTextFieldCell { + #[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] } + } + #[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(drawInteriorWithFrame:inView:))] - fn draw_interior(&self, frame: CGRect, view: &NSView) { - draw_on_font_baseline(self, || unsafe { - msg_send![super(self), drawInteriorWithFrame: frame, inView: view] - }); - } - #[unsafe(method(cellSizeForBounds:))] fn cell_size_for_bounds(&self, bounds: CGRect) -> objc2_core_foundation::CGSize { let insets = self.ivars().get(); @@ -114,6 +113,12 @@ define_class!( pub struct PerryInsetSecureTextFieldCell; impl PerryInsetSecureTextFieldCell { + #[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] } + } + #[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())); @@ -163,20 +168,6 @@ define_class!( } ); -/// Single-line mode draws idle text on the baseline of the control size's -/// system font, not the cell's own font; the field editor uses the cell's font, -/// so a custom font would jump when editing starts. Drawing with single-line -/// mode off puts the idle text where the field editor draws it, newlines -/// included. -fn draw_on_font_baseline(cell: &NSCell, draw: impl FnOnce()) { - if !cell.usesSingleLineMode() { - return draw(); - } - cell.setUsesSingleLineMode(false); - draw(); - cell.setUsesSingleLineMode(true); -} - 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. @@ -210,49 +201,57 @@ 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]; +define_class!( + #[unsafe(super(NSTextField))] + #[name = "PerryInsetTextField"] + pub struct PerryInsetTextField; + + impl PerryInsetTextField { + #[unsafe(method(cellClass))] + fn cell_class() -> &'static AnyClass { + PerryInsetTextFieldCell::class() + } } -} +); -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]; +define_class!( + #[unsafe(super(NSSecureTextField))] + #[name = "PerryInsetSecureTextField"] + pub struct PerryInsetSecureTextField; + + impl PerryInsetSecureTextField { + #[unsafe(method(cellClass))] + fn cell_class() -> &'static AnyClass { + PerryInsetSecureTextFieldCell::class() + } } +); + +/// 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![PerryInsetTextField::class(), textFieldWithString: string] }; + field.into_super() } -/// 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()); - } +/// 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![PerryInsetSecureTextField::class(), textFieldWithString: string] }; + field.into_super() +} + +/// 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![PerryInsetTextField::class(), labelWithString: string] }; + label.into_super() } /// Apply padding to AppKit widgets with a native content-inset mechanism. diff --git a/crates/perry-ui-macos/src/widgets/securefield.rs b/crates/perry-ui-macos/src/widgets/securefield.rs index ad00e42bef..fb353ff09e 100644 --- a/crates/perry-ui-macos/src/widgets/securefield.rs +++ b/crates/perry-ui-macos/src/widgets/securefield.rs @@ -1,8 +1,8 @@ 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::{define_class, msg_send, AnyThread, DefinedClass}; +use objc2_app_kit::{NSTextField, NSView}; use objc2_foundation::{ MainThreadMarker, NSNotification, NSNotificationCenter, NSObject, NSString, }; @@ -98,18 +98,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 = super::padding::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..97a2cd1cbf 100644 --- a/crates/perry-ui-macos/src/widgets/text.rs +++ b/crates/perry-ui-macos/src/widgets/text.rs @@ -132,8 +132,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 = super::padding::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..848b1b22a6 100644 --- a/crates/perry-ui-macos/src/widgets/textfield.rs +++ b/crates/perry-ui-macos/src/widgets/textfield.rs @@ -3,7 +3,7 @@ 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_app_kit::{NSTextField, NSView}; use objc2_foundation::{ MainThreadMarker, NSNotification, NSNotificationCenter, NSObject, NSRange, NSRunLoop, NSString, }; @@ -214,25 +214,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 = super::padding::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); diff --git a/crates/perry-ui-macos/tests/native_textfield_baseline.rs b/crates/perry-ui-macos/tests/native_textfield_baseline.rs deleted file mode 100644 index fe099afb33..0000000000 --- a/crates/perry-ui-macos/tests/native_textfield_baseline.rs +++ /dev/null @@ -1,119 +0,0 @@ -// A TextField must draw its text at the same height whether or not it is -// editing (#11661). The field editor draws on the font's own baseline, so the -// idle cell must too, for every font and control size. -// -// 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::MainThreadOnly; - use objc2_app_kit::{ - 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; - - if std::env::args().any(|arg| arg == "--list") { - println!("native_textfield_baseline: test"); - return; - } - let mtm = MainThreadMarker::new().expect("TextField baseline test runs on the main thread"); - let _app = NSApplication::sharedApplication(mtm); - let empty = perry_runtime::string::js_string_from_bytes(b"".as_ptr(), 0); - - let cases = [ - ("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), - ]; - let mut failures = Vec::new(); - for secure in [false, true] { - for (family, size, control_size) in cases { - let handle = if secure { - widgets::securefield::create(empty.cast(), 0.0) - } else { - widgets::textfield::create(empty.cast(), 0.0) - }; - widgets::textfield::set_borderless(handle, 1.0); - widgets::textfield::set_text_str(handle, "Hxg"); - 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(family), size) - .expect("the font is installed"); - field.setFont(Some(&font)); - field.setControlSize(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, 60.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, 100.0)), - NSWindowStyleMask::Titled, - NSBackingStoreType::Buffered, - false, - ) - }; - unsafe { window.setReleasedWhenClosed(false) }; - let content = window.contentView().unwrap(); - content.addSubview(&view); - - let single_line = field.cell().unwrap().usesSingleLineMode(); - let idle = ink_top(&content, field_frame); - assert_eq!( - field.cell().unwrap().usesSingleLineMode(), - single_line, - "drawing the idle field must leave its single-line mode as it was" - ); - 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(3, 0)); - editor.setInsertionPointColor(Some(&NSColor::whiteColor())); - let editing = ink_top(&content, field_frame); - window.close(); - - let name = format!( - "{} {family} {size} {control_size:?}", - if secure { "SecureField" } else { "TextField" } - ); - println!("{name}: idle top {idle}, editing top {editing}"); - if (idle - editing).abs() > 0.51 { - failures.push(format!("{name}: idle {idle} vs editing {editing}")); - } - } - } - assert!( - failures.is_empty(), - "text moves when editing starts: {failures:#?}" - ); - println!("PASS native TextField baseline"); - - // The top of the dark ink inside `rect`, in points below its top edge. - fn ink_top(view: &NSView, rect: CGRect) -> f64 { - let bitmap = view.bitmapImageRepForCachingDisplayInRect(rect).unwrap(); - view.cacheDisplayInRect_toBitmapImageRep(rect, &bitmap); - let scale = bitmap.pixelsHigh() as f64 / rect.size.height; - 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 { - return y as f64 / scale; - } - } - } - panic!("the field draws no text"); - } -} - -#[cfg(not(target_os = "macos"))] -fn main() {} 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..e97bea41a2 --- /dev/null +++ b/crates/perry-ui-macos/tests/native_textfield_cell.rs @@ -0,0 +1,228 @@ +// 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). +// +// 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::MainThreadOnly; + use objc2_app_kit::{ + 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; + + // Ink positions are read from a 2x bitmap, so one pixel is 0.5pt. + const PIXEL_TOLERANCE: f64 = 0.51; + + struct Case { + secure: bool, + 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, + } + + 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 assignee = currentUser() AND statusCategory != Done AND project = SU"; + let mut cases = Vec::new(); + for secure in [false, true] { + 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, + family, + size, + control_size, + text: "Hxg", + padding: None, + }); + } + cases.push(Case { + secure, + family: "Helvetica", + size: 16.0, + control_size: NSControlSize::Regular, + text: long, + padding: None, + }); + cases.push(Case { + secure, + 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.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); + + // 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() > PIXEL_TOLERANCE + || fits && (drawn.idle.left - drawn.editing.left).abs() > PIXEL_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() > PIXEL_TOLERANCE + || (drawn.idle.left - unpadded.idle.left - left).abs() > PIXEL_TOLERANCE + { + failures.push(format!( + "{name}: padding moved the text by ({}, {})", + drawn.idle.top - unpadded.idle.top, + drawn.idle.left - unpadded.idle.left + )); + } + } + } + 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) + }; + widgets::textfield::set_borderless(handle, 1.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) }; + 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, + } + } +} + +#[cfg(not(target_os = "macos"))] +fn main() {} From a76f708ff885f6ee6780570170c3e83b861403e7 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 09:28:13 +1300 Subject: [PATCH 06/19] test(ui-macos): describe the label-colour constraint without the removed helper (#11661) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- crates/perry-ui-macos/tests/native_text_color.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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. // From 79087f9bc68586ba382e3df292d989ea69e403df Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 09:39:07 +1300 Subject: [PATCH 07/19] test(ui-macos): cover padding, one-line, secure and label colour in the smoke test (#11661) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- .../test_issue_11661_textfield_baseline.ts | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/test-files/test_issue_11661_textfield_baseline.ts b/test-files/test_issue_11661_textfield_baseline.ts index 62fff788ee..ce5c9856fe 100644 --- a/test-files/test_issue_11661_textfield_baseline.ts +++ b/test-files/test_issue_11661_textfield_baseline.ts @@ -1,25 +1,53 @@ -// Smoke test for issue #11661: on macOS a TextField must draw its text at the -// same height whether or not it is editing. -// Run the built app. The first field opens focused, so it draws through the -// field editor. The text in both fields must sit at the same height in its -// yellow box, and clicking into the second field must not move its text. -import { App, TextField, VStack, textSetFontFamily, textfieldSetBackgroundColor, textfieldSetBorderless, textfieldSetFontSize, textfieldSetString, textfieldSetTextColor, widgetSetBackgroundColor, widgetSetHeight } from "perry/ui" +// 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 field 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 field(text: string, size: number) { - const f = TextField("", () => {}) +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, 40) + widgetSetHeight(f, height) +} + +function field(text: string, size: number) { + const f = TextField("", () => {}) + style(f, size, 40) textfieldSetString(f, text) return f } -const body = VStack(20, [ + +const padded = field("Hxg padded 16pt", 16) +setPadding(padded, 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: assignee = currentUser() AND statusCategory != Done AND project = SU", 16), + padded, + secure, + label, ]) widgetSetBackgroundColor(body, 1, 1, 1, 1) -App({ title: "issue 11661 TextField baseline", width: 400, height: 240, windowState: "normal", body }) +App({ title: "issue 11661 TextField cell", width: 420, height: 420, windowState: "normal", body }) From 0213b169fd40d3a2784f94f02a6abf8205b17d13 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 09:52:14 +1300 Subject: [PATCH 08/19] refactor(ui-macos): rename the field classes to PerryTextField and PerrySecureTextField (#11661) Every TextField, SecureField, Text and AttributedText widget uses these classes, so their names do not need to say that the cell insets text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- changelog.d/11665-macos-textfield-baseline.md | 2 +- crates/perry-ui-macos/src/widgets/padding.rs | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md index d953c9cd56..f1fcf90055 100644 --- a/changelog.d/11665-macos-textfield-baseline.md +++ b/changelog.d/11665-macos-textfield-baseline.md @@ -5,7 +5,7 @@ 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. `PerryInsetTextField` and `PerryInsetSecureTextField` now override +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 diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index 5e324df929..b9e92e08ac 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -203,10 +203,10 @@ fn padded_size( define_class!( #[unsafe(super(NSTextField))] - #[name = "PerryInsetTextField"] - pub struct PerryInsetTextField; + #[name = "PerryTextField"] + pub struct PerryTextField; - impl PerryInsetTextField { + impl PerryTextField { #[unsafe(method(cellClass))] fn cell_class() -> &'static AnyClass { PerryInsetTextFieldCell::class() @@ -216,10 +216,10 @@ define_class!( define_class!( #[unsafe(super(NSSecureTextField))] - #[name = "PerryInsetSecureTextField"] - pub struct PerryInsetSecureTextField; + #[name = "PerrySecureTextField"] + pub struct PerrySecureTextField; - impl PerryInsetSecureTextField { + impl PerrySecureTextField { #[unsafe(method(cellClass))] fn cell_class() -> &'static AnyClass { PerryInsetSecureTextFieldCell::class() @@ -230,8 +230,8 @@ define_class!( /// 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![PerryInsetTextField::class(), textFieldWithString: string] }; + let field: Retained = + unsafe { msg_send![PerryTextField::class(), textFieldWithString: string] }; field.into_super() } @@ -241,16 +241,16 @@ pub(crate) fn secure_text_field( string: &NSString, _mtm: MainThreadMarker, ) -> Retained { - let field: Retained = - unsafe { msg_send![PerryInsetSecureTextField::class(), textFieldWithString: string] }; + let field: Retained = + unsafe { msg_send![PerrySecureTextField::class(), textFieldWithString: string] }; field.into_super() } /// 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![PerryInsetTextField::class(), labelWithString: string] }; + let label: Retained = + unsafe { msg_send![PerryTextField::class(), labelWithString: string] }; label.into_super() } From b99d512a3dd0d36ab6afd1542fbcd144b432b1c0 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 11:49:17 +1300 Subject: [PATCH 09/19] fix(ui-macos): pass layer colours as a typed CGColor pointer (#11661) Every layer background, border and shadow colour panicked in a debug build. native_dynamic_colors failed on that panic, and now passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- crates/perry-ui-macos/src/srgb.rs | 15 ++++++++++++++- .../perry-ui-macos/tests/native_dynamic_colors.rs | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/perry-ui-macos/src/srgb.rs b/crates/perry-ui-macos/src/srgb.rs index 9e31b8696a..189cf8bfb6 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; a `c_void` pointer encodes as `^v`, +/// which a debug build's message check rejects. +#[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/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] From 28f8ecb251edd26322e5f54e7a36df24609c4cd1 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 11:50:59 +1300 Subject: [PATCH 10/19] fix(ui-macos): pad text fields as CSS padding does (#11661) A padded TextField or SecureField with a bezel or a border moved its text by the padding when editing started. Its background also stopped at the padding, where a web input's background fills it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- changelog.d/11665-macos-textfield-baseline.md | 13 ++ crates/perry-ui-macos/src/widgets/padding.rs | 24 ++- .../perry-ui-macos/src/widgets/textfield.rs | 11 +- .../tests/native_textfield_cell.rs | 146 +++++++++++++----- .../test_issue_11661_textfield_baseline.ts | 11 +- test-parity/known_failures.json | 9 ++ 6 files changed, 158 insertions(+), 56 deletions(-) diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md index f1fcf90055..3b6138f732 100644 --- a/changelog.d/11665-macos-textfield-baseline.md +++ b/changelog.d/11665-macos-textfield-baseline.md @@ -11,3 +11,16 @@ 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. Option-Return in a `TextField` inserts a newline, as it does in a stock `NSTextField`. + +`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. + +Layer colours reach CoreAnimation as a typed `CGColor` pointer. As a `c_void` +pointer, `widgetSetBackgroundColor`, border colours and shadow colours +panicked in a debug build's message check. diff --git a/crates/perry-ui-macos/src/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index b9e92e08ac..225d5a499b 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -57,10 +57,14 @@ define_class!( unsafe { msg_send![super(this), initTextCell: string] } } - #[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] } + // 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:))] @@ -119,10 +123,14 @@ define_class!( unsafe { msg_send![super(this), initTextCell: string] } } - #[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] } + // 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:))] diff --git a/crates/perry-ui-macos/src/widgets/textfield.rs b/crates/perry-ui-macos/src/widgets/textfield.rs index 848b1b22a6..f4950eb19b 100644 --- a/crates/perry-ui-macos/src/widgets/textfield.rs +++ b/crates/perry-ui-macos/src/widgets/textfield.rs @@ -534,12 +534,11 @@ 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)); - } + // The cell's own background fills only its 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); } } diff --git a/crates/perry-ui-macos/tests/native_textfield_cell.rs b/crates/perry-ui-macos/tests/native_textfield_cell.rs index e97bea41a2..bc1d616c61 100644 --- a/crates/perry-ui-macos/tests/native_textfield_cell.rs +++ b/crates/perry-ui-macos/tests/native_textfield_cell.rs @@ -3,26 +3,37 @@ // - 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). +// - `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::MainThreadOnly; + use objc2::{ClassType, MainThreadOnly}; use objc2_app_kit::{ - NSApplication, NSBackingStoreType, NSColor, NSControlSize, NSFont, NSTextField, NSTextView, - NSView, NSWindow, NSWindowStyleMask, + 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; - // Ink positions are read from a 2x bitmap, so one pixel is 0.5pt. - const PIXEL_TOLERANCE: f64 = 0.51; + /// 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, @@ -42,6 +53,8 @@ fn main() { top: f64, left: f64, bottom: f64, + /// The size of one bitmap pixel, in points. + pixel: f64, } if std::env::args().any(|arg| arg == "--list") { @@ -54,49 +67,55 @@ fn main() { let long = "Hxg assignee = currentUser() AND statusCategory != Done AND project = SU"; let mut cases = Vec::new(); for secure in [false, true] { - 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), - ] { + 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, - family, - size, - control_size, - text: "Hxg", + 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)), + }); } - cases.push(Case { - secure, - family: "Helvetica", - size: 16.0, - control_size: NSControlSize::Regular, - text: long, - padding: None, - }); - cases.push(Case { - secure, - 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, @@ -106,13 +125,14 @@ fn main() { ); 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() > PIXEL_TOLERANCE - || fits && (drawn.idle.left - drawn.editing.left).abs() > PIXEL_TOLERANCE + 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")); } @@ -129,8 +149,8 @@ fn main() { }, mtm, ); - if (drawn.idle.top - unpadded.idle.top - top).abs() > PIXEL_TOLERANCE - || (drawn.idle.left - unpadded.idle.left - left).abs() > PIXEL_TOLERANCE + 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 ({}, {})", @@ -140,6 +160,45 @@ fn main() { } } } + 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"); @@ -150,7 +209,11 @@ fn main() { } else { widgets::textfield::create(empty.cast(), 0.0) }; - widgets::textfield::set_borderless(handle, 1.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); @@ -177,6 +240,10 @@ fn main() { ) }; 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); @@ -220,6 +287,7 @@ fn main() { top: bounds.0 as f64 / scale, left: bounds.1 as f64 / scale, bottom: bounds.2 as f64 / scale, + pixel: 1.0 / scale, } } } diff --git a/test-files/test_issue_11661_textfield_baseline.ts b/test-files/test_issue_11661_textfield_baseline.ts index ce5c9856fe..52d6e83032 100644 --- a/test-files/test_issue_11661_textfield_baseline.ts +++ b/test-files/test_issue_11661_textfield_baseline.ts @@ -7,8 +7,8 @@ // - 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 field and the padded label must inset their text by 12 at the -// top and 24 at the left (#9954, #10159). +// - 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" @@ -32,6 +32,10 @@ function field(text: string, size: number) { 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") @@ -46,8 +50,9 @@ const body = VStack(12, [ field("Hxg third 28pt", 28), field("Hxg long value: assignee = currentUser() AND statusCategory != Done AND project = SU", 16), padded, + bezeled, secure, label, ]) widgetSetBackgroundColor(body, 1, 1, 1, 1) -App({ title: "issue 11661 TextField cell", width: 420, height: 420, windowState: "normal", body }) +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", From f353f8c506d0c309024200cc60abd18e236a0baa Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 14:54:01 +1300 Subject: [PATCH 11/19] fix(ui-macos): keep TextField and SecureField to one line, as a web input does (#11661) Without single-line mode, a pasted multi-line value kept its newlines and the field grew taller. The rules match Chrome's for an input element. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- changelog.d/11665-macos-textfield-baseline.md | 8 +- crates/perry-ui-macos/Cargo.toml | 5 + crates/perry-ui-macos/src/widgets/padding.rs | 71 ++++++- .../perry-ui-macos/src/widgets/textfield.rs | 67 +++++- .../tests/native_textfield_newlines.rs | 195 ++++++++++++++++++ 5 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 crates/perry-ui-macos/tests/native_textfield_newlines.rs diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md index 3b6138f732..61e1a0f981 100644 --- a/changelog.d/11665-macos-textfield-baseline.md +++ b/changelog.d/11665-macos-textfield-baseline.md @@ -9,8 +9,12 @@ size, so a custom font sat 2pt high at 16pt Helvetica and clipped from about `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. Option-Return in a `TextField` inserts a -newline, as it does in a stock `NSTextField`. +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 diff --git a/crates/perry-ui-macos/Cargo.toml b/crates/perry-ui-macos/Cargo.toml index 71b654f631..e2c56985e3 100644 --- a/crates/perry-ui-macos/Cargo.toml +++ b/crates/perry-ui-macos/Cargo.toml @@ -117,6 +117,11 @@ 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/widgets/padding.rs b/crates/perry-ui-macos/src/widgets/padding.rs index 225d5a499b..96441517bd 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -1,11 +1,12 @@ use objc2::rc::{Allocated, Retained}; -use objc2::runtime::{AnyClass, AnyObject}; +use objc2::runtime::{AnyClass, AnyObject, Sel}; use objc2::{define_class, msg_send, ClassType, DefinedClass}; use objc2_app_kit::{ - NSEvent, NSSecureTextField, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, NSView, + NSEvent, NSSecureTextField, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, + NSTextView, NSView, }; use objc2_core_foundation::CGRect; -use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol, NSString}; +use objc2_foundation::{MainThreadMarker, NSEdgeInsets, NSObjectProtocol, NSRange, NSString}; use std::cell::Cell; mod button; @@ -219,6 +220,38 @@ define_class!( fn cell_class() -> &'static AnyClass { PerryInsetTextFieldCell::class() } + + #[unsafe(method(setStringValue:))] + fn set_string_value(&self, value: &NSString) { + let value = super::textfield::one_line_value(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 super::textfield::entered_text_with_spaces(replacement) { + // 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] } + } } ); @@ -232,6 +265,38 @@ define_class!( fn cell_class() -> &'static AnyClass { PerryInsetSecureTextFieldCell::class() } + + #[unsafe(method(setStringValue:))] + fn set_string_value(&self, value: &NSString) { + let value = super::textfield::one_line_value(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 super::textfield::entered_text_with_spaces(replacement) { + // 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] } + } } ); diff --git a/crates/perry-ui-macos/src/widgets/textfield.rs b/crates/perry-ui-macos/src/widgets/textfield.rs index f4950eb19b..0d509d7764 100644 --- a/crates/perry-ui-macos/src/widgets/textfield.rs +++ b/crates/perry-ui-macos/src/widgets/textfield.rs @@ -2,7 +2,7 @@ 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::{define_class, msg_send, AnyThread, DefinedClass, Message}; use objc2_app_kit::{NSTextField, NSView}; use objc2_foundation::{ MainThreadMarker, NSNotification, NSNotificationCenter, NSObject, NSRange, NSRunLoop, NSString, @@ -578,3 +578,68 @@ pub fn set_text_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { } } } + +/// `value` as a TextField stores it: `without_line_breaks` applied. +pub(crate) fn one_line_value(value: &NSString) -> Retained { + match without_line_breaks(&value.to_string()) { + std::borrow::Cow::Borrowed(_) => value.retain(), + std::borrow::Cow::Owned(text) => NSString::from_str(&text), + } +} + +/// The text to insert in place of `entered`, when it holds a line break. +pub(crate) fn entered_text_with_spaces(entered: Option<&NSString>) -> Option> { + line_breaks_as_spaces(&entered?.to_string()).map(|text| NSString::from_str(&text)) +} + +/// The field editor commands that would insert a line break. A web `` +/// ignores Option-Return and Control-Return, so a TextField does too. +pub(crate) fn is_line_break_command(command: Sel) -> bool { + [ + objc2::sel!(insertNewlineIgnoringFieldEditor:), + objc2::sel!(insertLineBreak:), + objc2::sel!(insertParagraphSeparator:), + ] + .contains(&command) +} + +/// A TextField holds one line, as a web `` does. A value set in code +/// drops its line breaks, as `input.value = …` does. +pub(crate) fn without_line_breaks(text: &str) -> std::borrow::Cow<'_, str> { + if text.contains(['\r', '\n']) { + text.replace(['\r', '\n'], "").into() + } else { + text.into() + } +} + +/// Text typed, pasted or dropped into a TextField turns each line break into +/// one space, as a web `` does. `None` when the text has no line break. +pub(crate) fn line_breaks_as_spaces(text: &str) -> Option { + text.contains(['\r', '\n']) + .then(|| text.replace("\r\n", " ").replace(['\r', '\n'], " ")) +} + +#[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), + ] { + assert_eq!( + ( + without_line_breaks(text).as_ref(), + line_breaks_as_spaces(text).as_deref() + ), + (set_in_code, entered), + "{text:?}" + ); + } + } +} 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..63f9bf4b3e --- /dev/null +++ b/crates/perry-ui-macos/tests/native_textfield_newlines.rs @@ -0,0 +1,195 @@ +// 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. +// +// AppKit must run on the process main thread, so this test has no Rust harness. +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn js_closure_alloc(func_ptr: *const u8, capture_count: u32) -> *mut u8; + 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:?}")); + } + } + } + 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. + extern "C" fn on_change(_closure: *const u8, _value: f64) -> f64 { + f64::from_bits(0x7FFC_0000_0000_0001) + } + extern "C" fn on_submit(_closure: *const u8, _value: f64) -> f64 { + SUBMITS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + f64::from_bits(0x7FFC_0000_0000_0001) + } + let closure = |func: extern "C" fn(*const u8, f64) -> f64| unsafe { + js_nanbox_pointer(js_closure_alloc(func as *const u8, 0) as i64) + }; + let on_change = closure(on_change); + 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(on_submit)); + } + 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() {} From 981e31d246c97d43f066238e51988848e14637ed Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 16:12:05 +1300 Subject: [PATCH 12/19] refactor(ui-macos): move the field classes next to their widgets (#11661) padding.rs keeps the inset cells. PerryTextField moves to textfield.rs, PerrySecureTextField to securefield.rs, and the label factory to text.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- .../src/widgets/attributed_text.rs | 2 +- crates/perry-ui-macos/src/widgets/padding.rs | 128 +----------------- .../perry-ui-macos/src/widgets/securefield.rs | 66 ++++++++- crates/perry-ui-macos/src/widgets/text.rs | 11 +- .../perry-ui-macos/src/widgets/textfield.rs | 61 ++++++++- 5 files changed, 133 insertions(+), 135 deletions(-) diff --git a/crates/perry-ui-macos/src/widgets/attributed_text.rs b/crates/perry-ui-macos/src/widgets/attributed_text.rs index d64346f873..c0e1e95db2 100644 --- a/crates/perry-ui-macos/src/widgets/attributed_text.rs +++ b/crates/perry-ui-macos/src/widgets/attributed_text.rs @@ -34,7 +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 = super::padding::label(&empty, 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 96441517bd..bb9c395764 100644 --- a/crates/perry-ui-macos/src/widgets/padding.rs +++ b/crates/perry-ui-macos/src/widgets/padding.rs @@ -1,12 +1,9 @@ use objc2::rc::{Allocated, Retained}; -use objc2::runtime::{AnyClass, AnyObject, Sel}; -use objc2::{define_class, msg_send, ClassType, DefinedClass}; -use objc2_app_kit::{ - NSEvent, NSSecureTextField, NSSecureTextFieldCell, NSText, NSTextField, NSTextFieldCell, - NSTextView, NSView, -}; +use objc2::runtime::{AnyClass, AnyObject}; +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, NSRange, NSString}; +use objc2_foundation::{NSEdgeInsets, NSObjectProtocol, NSString}; use std::cell::Cell; mod button; @@ -210,123 +207,6 @@ fn padded_size( ) } -define_class!( - #[unsafe(super(NSTextField))] - #[name = "PerryTextField"] - pub struct PerryTextField; - - impl PerryTextField { - #[unsafe(method(cellClass))] - fn cell_class() -> &'static AnyClass { - PerryInsetTextFieldCell::class() - } - - #[unsafe(method(setStringValue:))] - fn set_string_value(&self, value: &NSString) { - let value = super::textfield::one_line_value(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 super::textfield::entered_text_with_spaces(replacement) { - // 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] } - } - } -); - -define_class!( - #[unsafe(super(NSSecureTextField))] - #[name = "PerrySecureTextField"] - pub struct PerrySecureTextField; - - impl PerrySecureTextField { - #[unsafe(method(cellClass))] - fn cell_class() -> &'static AnyClass { - PerryInsetSecureTextFieldCell::class() - } - - #[unsafe(method(setStringValue:))] - fn set_string_value(&self, value: &NSString) { - let value = super::textfield::one_line_value(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 super::textfield::entered_text_with_spaces(replacement) { - // 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] } - } - } -); - -/// 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() -} - -/// 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() -} - -/// 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![PerryTextField::class(), labelWithString: string] }; - label.into_super() -} - /// 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 fb353ff09e..69864fd793 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}; -use objc2_app_kit::{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::one_line_value(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 super::textfield::entered_text_with_spaces(replacement) { + // 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,7 +154,7 @@ pub fn create(placeholder_ptr: *const u8, on_change: f64) -> i64 { let ns_placeholder = NSString::from_str(&placeholder); unsafe { - let text_field = super::padding::secure_text_field(&NSString::from_str(""), mtm); + let text_field = secure_text_field(&NSString::from_str(""), mtm); text_field.setPlaceholderString(Some(&ns_placeholder)); let view: Retained = Retained::cast_unchecked(text_field); diff --git a/crates/perry-ui-macos/src/widgets/text.rs b/crates/perry-ui-macos/src/widgets/text.rs index 97a2cd1cbf..05db3d750c 100644 --- a/crates/perry-ui-macos/src/widgets/text.rs +++ b/crates/perry-ui-macos/src/widgets/text.rs @@ -1,5 +1,6 @@ use crate::srgb; use objc2::rc::Retained; +use objc2::{msg_send, ClassType}; use objc2_app_kit::{NSTextField, NSView}; use objc2_foundation::{MainThreadMarker, NSString}; use std::cell::RefCell; @@ -125,6 +126,14 @@ pub fn set_line_height(handle: i64, multiple: f64) { refresh_spacing(handle); } +/// 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![super::textfield::PerryTextField::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,7 +141,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 = super::padding::label(&ns_string, 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 0d509d7764..720ec4e06a 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, Message}; -use objc2_app_kit::{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 = one_line_value(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 entered_text_with_spaces(replacement) { + // 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,7 +267,7 @@ pub fn create(placeholder_ptr: *const u8, on_change: f64) -> i64 { let ns_placeholder = NSString::from_str(&placeholder); unsafe { - let text_field = super::padding::text_field(&NSString::from_str(""), mtm); + let text_field = text_field(&NSString::from_str(""), mtm); text_field.setPlaceholderString(Some(&ns_placeholder)); let view: Retained = Retained::cast_unchecked(text_field); From 092d7d38edb60952f237bbd3b06364569e9fd12e Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 16:34:49 +1300 Subject: [PATCH 13/19] fix(ui-macos): keep line breaks in Text labels (#11661) Text and AttributedText were built as PerryTextField, so they took the input's one-line rules. Text("a\nb") showed "ab". Labels now use PerryLabel, which takes only the inset cell. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- crates/perry-ui-macos/src/widgets/text.rs | 21 ++++++++++++++++--- .../tests/native_textfield_newlines.rs | 18 ++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/perry-ui-macos/src/widgets/text.rs b/crates/perry-ui-macos/src/widgets/text.rs index 05db3d750c..2e1bc863f4 100644 --- a/crates/perry-ui-macos/src/widgets/text.rs +++ b/crates/perry-ui-macos/src/widgets/text.rs @@ -1,6 +1,7 @@ use crate::srgb; use objc2::rc::Retained; -use objc2::{msg_send, ClassType}; +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; @@ -126,11 +127,25 @@ 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![super::textfield::PerryTextField::class(), labelWithString: string] }; + let label: Retained = + unsafe { msg_send![PerryLabel::class(), labelWithString: string] }; label.into_super() } diff --git a/crates/perry-ui-macos/tests/native_textfield_newlines.rs b/crates/perry-ui-macos/tests/native_textfield_newlines.rs index 63f9bf4b3e..75e14a65d2 100644 --- a/crates/perry-ui-macos/tests/native_textfield_newlines.rs +++ b/crates/perry-ui-macos/tests/native_textfield_newlines.rs @@ -3,6 +3,7 @@ // - 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")] @@ -91,6 +92,23 @@ fn main() { } } } + 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"); From b5ae18488e79de609d856c43834d1bd745b69433 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Wed, 30 Sep 2026 16:45:30 +1300 Subject: [PATCH 14/19] refactor(ui-macos): state unused alternatives as hypotheticals in comments (#11661) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgNvfPkqQmm5JRZUuEXv5y --- crates/perry-ui-macos/src/srgb.rs | 4 ++-- crates/perry-ui-macos/src/widgets/textfield.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/perry-ui-macos/src/srgb.rs b/crates/perry-ui-macos/src/srgb.rs index 189cf8bfb6..94d2c54c62 100644 --- a/crates/perry-ui-macos/src/srgb.rs +++ b/crates/perry-ui-macos/src/srgb.rs @@ -8,8 +8,8 @@ use std::ffi::c_void; use std::sync::OnceLock; /// CoreGraphics' opaque colour. A typed pointer encodes as `^{CGColor=}`, the -/// type CALayer's colour properties declare; a `c_void` pointer encodes as `^v`, -/// which a debug build's message check rejects. +/// 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], diff --git a/crates/perry-ui-macos/src/widgets/textfield.rs b/crates/perry-ui-macos/src/widgets/textfield.rs index 720ec4e06a..b24b583dd0 100644 --- a/crates/perry-ui-macos/src/widgets/textfield.rs +++ b/crates/perry-ui-macos/src/widgets/textfield.rs @@ -587,8 +587,9 @@ 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) { - // The cell's own background fills only its text area, inside the - // padding. The layer fills the whole field, as a CSS background does. + // 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); From 1353f992ee823c6208afef987f6dfb917b8ccaa7 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Thu, 1 Oct 2026 09:34:47 +1300 Subject: [PATCH 15/19] test(ui-macos): build the newlines test's closures from JsFunctionInfo (#11661) js_closure_alloc takes a JsFunctionInfo, and a body takes `this`. The test passed a bare function pointer, so a call read a garbage arity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2cPfS5Hty6uwVHHayNJqQ --- .../tests/native_textfield_newlines.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/perry-ui-macos/tests/native_textfield_newlines.rs b/crates/perry-ui-macos/tests/native_textfield_newlines.rs index 75e14a65d2..fe6bb713b9 100644 --- a/crates/perry-ui-macos/tests/native_textfield_newlines.rs +++ b/crates/perry-ui-macos/tests/native_textfield_newlines.rs @@ -8,7 +8,6 @@ // AppKit must run on the process main thread, so this test has no Rust harness. #[cfg(target_os = "macos")] unsafe extern "C" { - fn js_closure_alloc(func_ptr: *const u8, capture_count: u32) -> *mut u8; fn js_nanbox_pointer(ptr: i64) -> f64; } @@ -116,17 +115,18 @@ fn main() { /// 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. - extern "C" fn on_change(_closure: *const u8, _value: f64) -> f64 { + 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 u8, _value: f64) -> f64 { + 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 = |func: extern "C" fn(*const u8, f64) -> f64| unsafe { - js_nanbox_pointer(js_closure_alloc(func as *const u8, 0) as i64) + let closure = |info: *const JsFunctionInfo| unsafe { + js_nanbox_pointer(perry_runtime::closure::js_closure_alloc(info, 0) as i64) }; - let on_change = closure(on_change); + 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) @@ -134,7 +134,7 @@ fn main() { widgets::textfield::create(empty.cast(), on_change) }; if !secure { - widgets::textfield::set_on_submit(handle, closure(on_submit)); + 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(); From 1d3fd4da9d73e3ad1d5eddc875fc1d9c119e70c4 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Thu, 1 Oct 2026 09:36:38 +1300 Subject: [PATCH 16/19] refactor(ui-macos): name the line-break helpers for what they do (#11661) strip_line_breaks and replace_line_breaks_with_spaces take and return an NSString, so one name covers each operation. The doc comment on is_line_break_command says that Return still submits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2cPfS5Hty6uwVHHayNJqQ --- .../perry-ui-macos/src/widgets/securefield.rs | 4 +- .../perry-ui-macos/src/widgets/textfield.rs | 56 +++++++++---------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/crates/perry-ui-macos/src/widgets/securefield.rs b/crates/perry-ui-macos/src/widgets/securefield.rs index 69864fd793..0a3184f40a 100644 --- a/crates/perry-ui-macos/src/widgets/securefield.rs +++ b/crates/perry-ui-macos/src/widgets/securefield.rs @@ -99,7 +99,7 @@ define_class!( #[unsafe(method(setStringValue:))] fn set_string_value(&self, value: &NSString) { - let value = super::textfield::one_line_value(value); + let value = super::textfield::strip_line_breaks(value); unsafe { msg_send![super(self), setStringValue: &*value] } } @@ -110,7 +110,7 @@ define_class!( range: NSRange, replacement: Option<&NSString>, ) -> bool { - match super::textfield::entered_text_with_spaces(replacement) { + 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) => { diff --git a/crates/perry-ui-macos/src/widgets/textfield.rs b/crates/perry-ui-macos/src/widgets/textfield.rs index b24b583dd0..bb33627f9b 100644 --- a/crates/perry-ui-macos/src/widgets/textfield.rs +++ b/crates/perry-ui-macos/src/widgets/textfield.rs @@ -215,7 +215,7 @@ define_class!( #[unsafe(method(setStringValue:))] fn set_string_value(&self, value: &NSString) { - let value = one_line_value(value); + let value = strip_line_breaks(value); unsafe { msg_send![super(self), setStringValue: &*value] } } @@ -226,7 +226,7 @@ define_class!( range: NSRange, replacement: Option<&NSString>, ) -> bool { - match entered_text_with_spaces(replacement) { + 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) => { @@ -633,21 +633,31 @@ pub fn set_text_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { } } -/// `value` as a TextField stores it: `without_line_breaks` applied. -pub(crate) fn one_line_value(value: &NSString) -> Retained { - match without_line_breaks(&value.to_string()) { - std::borrow::Cow::Borrowed(_) => value.retain(), - std::borrow::Cow::Owned(text) => NSString::from_str(&text), +/// 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() } } -/// The text to insert in place of `entered`, when it holds a line break. -pub(crate) fn entered_text_with_spaces(entered: Option<&NSString>) -> Option> { - line_breaks_as_spaces(&entered?.to_string()).map(|text| NSString::from_str(&text)) +/// 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. +/// 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:), @@ -657,23 +667,6 @@ pub(crate) fn is_line_break_command(command: Sel) -> bool { .contains(&command) } -/// A TextField holds one line, as a web `` does. A value set in code -/// drops its line breaks, as `input.value = …` does. -pub(crate) fn without_line_breaks(text: &str) -> std::borrow::Cow<'_, str> { - if text.contains(['\r', '\n']) { - text.replace(['\r', '\n'], "").into() - } else { - text.into() - } -} - -/// Text typed, pasted or dropped into a TextField turns each line break into -/// one space, as a web `` does. `None` when the text has no line break. -pub(crate) fn line_breaks_as_spaces(text: &str) -> Option { - text.contains(['\r', '\n']) - .then(|| text.replace("\r\n", " ").replace(['\r', '\n'], " ")) -} - #[cfg(test)] mod tests { use super::*; @@ -686,12 +679,13 @@ mod tests { ("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!( ( - without_line_breaks(text).as_ref(), - line_breaks_as_spaces(text).as_deref() + strip_line_breaks(&text).to_string(), + replace_line_breaks_with_spaces(&text).map(|spaced| spaced.to_string()) ), - (set_in_code, entered), + (set_in_code.to_string(), entered.map(str::to_string)), "{text:?}" ); } From a6dbe7c70bbe422b2f61a7ed84898129fba8deff Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Thu, 1 Oct 2026 09:38:07 +1300 Subject: [PATCH 17/19] changelog: drop the CGColor note from the TextField fragment (#11665) The CGColor panic hit only a debug build of perry-ui-macos, which no release ships. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2cPfS5Hty6uwVHHayNJqQ --- changelog.d/11665-macos-textfield-baseline.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/changelog.d/11665-macos-textfield-baseline.md b/changelog.d/11665-macos-textfield-baseline.md index 61e1a0f981..a3795ef4d0 100644 --- a/changelog.d/11665-macos-textfield-baseline.md +++ b/changelog.d/11665-macos-textfield-baseline.md @@ -24,7 +24,3 @@ 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. - -Layer colours reach CoreAnimation as a typed `CGColor` pointer. As a `c_void` -pointer, `widgetSetBackgroundColor`, border colours and shadow colours -panicked in a debug build's message check. From a743dd4791d85e793251d6a5bb1d4764492c3685 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Thu, 1 Oct 2026 09:39:03 +1300 Subject: [PATCH 18/19] test(ui-macos): use generic text for the long TextField value (#11661) The long value was a Jira query from an app, which has no place in perry's tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2cPfS5Hty6uwVHHayNJqQ --- crates/perry-ui-macos/tests/native_textfield_cell.rs | 2 +- test-files/test_issue_11661_textfield_baseline.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/perry-ui-macos/tests/native_textfield_cell.rs b/crates/perry-ui-macos/tests/native_textfield_cell.rs index bc1d616c61..b6bf1c7316 100644 --- a/crates/perry-ui-macos/tests/native_textfield_cell.rs +++ b/crates/perry-ui-macos/tests/native_textfield_cell.rs @@ -64,7 +64,7 @@ fn main() { let mtm = MainThreadMarker::new().expect("TextField cell test runs on the main thread"); let _app = NSApplication::sharedApplication(mtm); - let long = "Hxg assignee = currentUser() AND statusCategory != Done AND project = SU"; + 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] { diff --git a/test-files/test_issue_11661_textfield_baseline.ts b/test-files/test_issue_11661_textfield_baseline.ts index 52d6e83032..a6e3422ac6 100644 --- a/test-files/test_issue_11661_textfield_baseline.ts +++ b/test-files/test_issue_11661_textfield_baseline.ts @@ -48,7 +48,7 @@ const body = VStack(12, [ field("Hxg first 16pt", 16), field("Hxg second 16pt", 16), field("Hxg third 28pt", 28), - field("Hxg long value: assignee = currentUser() AND statusCategory != Done AND project = SU", 16), + field("Hxg long value: the quick brown fox jumps over the lazy dog, then jumps over it again", 16), padded, bezeled, secure, From 6d834c89e23fc305d1646d9d6820935e47f07f43 Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Thu, 1 Oct 2026 12:06:03 +1300 Subject: [PATCH 19/19] style(ui-macos): rustfmt the newlines test's submit closure (#11661) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2cPfS5Hty6uwVHHayNJqQ --- crates/perry-ui-macos/tests/native_textfield_newlines.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/perry-ui-macos/tests/native_textfield_newlines.rs b/crates/perry-ui-macos/tests/native_textfield_newlines.rs index fe6bb713b9..0b5fedba3a 100644 --- a/crates/perry-ui-macos/tests/native_textfield_newlines.rs +++ b/crates/perry-ui-macos/tests/native_textfield_newlines.rs @@ -134,7 +134,10 @@ fn main() { widgets::textfield::create(empty.cast(), on_change) }; if !secure { - widgets::textfield::set_on_submit(handle, closure(perry_runtime::fn_info!(on_submit, 1))); + 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();