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