Skip to content

macOS: TextField text sits 2pt higher than NSTextField when not editing, and jumps down on focus #11661

Closed

Description

@steinybot

Summary

On macOS, a TextField that is not being edited draws its text 2pt higher than a stock AppKit NSTextField. When the field gains focus, AppKit's field editor draws the text at the stock position, so the text jumps down 2pt. A Text label is not affected.

Measured

Borderless fields, 40pt tall, 16pt Helvetica. Distance from the field's top edge to the top of the "H":

Not editing Editing
Stock AppKit NSTextField 3.5pt 3.5pt
Perry TextField 1.5pt 3.5pt

A Perry Text label and a stock NSTextField(labelWithString:) both put the "H" 3.5pt below the frame's top, so labels match.

The offset shows with zero insets, so it does not come from setPadding. Perry replaces each field's cell with PerryInsetTextFieldCell (install_text_field_cell in crates/perry-ui-macos/src/widgets/padding.rs), and the difference is in how that cell draws text when the field is not editing.

Repro

import { App, TextField, VStack, textSetFontFamily, textfieldFocus, textfieldSetBackgroundColor, textfieldSetBorderless, textfieldSetFontSize, textfieldSetString, textfieldSetTextColor, widgetSetBackgroundColor, widgetSetHeight } from "perry/ui"

function field(text: string) {
  const f = TextField("", () => {})
  textfieldSetBorderless(f, 1)
  textfieldSetFontSize(f, 16)
  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 first = field("Hxg first")
const second = field("Hxg second")
const body = VStack(40, [first, second])
widgetSetBackgroundColor(body, 1, 1, 1, 1)
App({ title: "probe", width: 400, height: 200, windowState: "normal", body })

The first field opens as the first responder, so it draws through the field editor. Its text sits 2pt lower in its box than the second field's text. Click into the second field and its text drops 2pt.

For the stock comparison, the same two fields built in Swift (NSTextField(frame:), isBezeled = false, isBordered = false, 16pt Helvetica, 40pt tall) draw the "H" 3.5pt below the top whether or not the field is editing.

Expected

A TextField draws its text in the same place whether or not it is editing, matching a stock NSTextField.

Perry: main at 4a1b788 with the sRGB change from #11613 applied, which touches only colour creation. macOS 26.6.2.

Activity

  1. steinybot commented on Sep 29, 2026

    @steinybot
    ContributorAuthor

    I dug into the root cause. It goes back to how we added padding to text fields, and it connects a string of earlier issues.

    A stock NSTextField(string:) (textFieldWithString:) is already a one-line field. Its cell is scrollable, does not wrap, clips, and does not use single-line mode. Borderless, in a 60pt frame, with Helvetica at 10, 16 and 28pt, it draws the idle text where the field editor draws it (2.5, 3.5 and 7.5pt from the top). Long values stay on one line and scroll.

    How we got here:

    1. For fix(perry/ui): padding dropped on non-stack widgets on Apple backends (macOS/iOS/tvOS/visionOS) #9954, fix(ui): apply padding to Apple leaf widgets #9960 added padding to TextField and SecureField. It swaps the cell AppKit builds for a new PerryInsetTextFieldCell (install_text_field_cell in crates/perry-ui-macos/src/widgets/padding.rs). A new cell does not keep the factory setup, so it wraps, does not scroll, and is not editable.
    2. I reported the wrapping in macOS TextField wraps to multiple lines instead of staying single-line (diverges from iOS/GTK4/Android/Windows) #10155 and blamed textFieldWithString:. That diagnosis was wrong. The factory cell does not wrap. The swapped cell does. My fix in fix(ui-macos): keep editable TextField single-line (#10155) #10157 set the one-line properties again by hand, and added usesSingleLineMode, which the factory does not set.
    3. For macOS setPadding is implemented for only 4 widget classes and silently no-ops on the rest (Button, labels, etc.) #10159, fix(ui): support macOS button and label padding #10234 added padding to Text labels with the same swap. It then restores the label's properties one at a time (install_label_cell).
    4. macOS: textSetColor is a no-op on a Text label (padding cell bakes attributedStringValue, overriding setTextColor:) #10856 came from that restore. It baked the label colour into the text, so textSetColor did nothing. My fix in fix(ui-macos): textSetColor no longer a no-op on a Text label #10862 changed the restore.
    5. This issue comes from usesSingleLineMode. In that mode AppKit draws idle text on the baseline of the system font for the control size (13pt at regular size), whatever the cell's font is. The field editor uses the real font. So the text moves on focus: 2pt high at 16pt Helvetica, 3pt low at 10pt, and clipped at the top from about 20pt.

    Each fix made sense on its own. Together they say the padding approach needs a rethink. #11665 fixes this issue by turning single-line mode off while the cell draws, which adds one more layer. I'll leave it as a draft and rework it.

    Proposed approach: let AppKit build our cell. NSTextField creates its cell from the cellClass class property. A small NSTextField subclass whose cellClass returns the inset cell keeps both the custom cell and the factory's one-line setup. It draws correctly at every size above. That removes the cell swap, the property restore in install_label_cell, and the single-line properties from #10157. The inset cell keeps only its padding methods.

    Whatever the new padding approach is, it has to keep all of these working:

    The native tests in perry-ui-macos cover padding and label colour. #11665 adds one for this issue.

    One behaviour change: without single-line mode, Option-Return inserts a newline, the same as in a stock NSTextField. TextArea is the multi-line widget, so that seems right to me.

  2. steinybot commented on Sep 30, 2026

    @steinybot
    ContributorAuthor

    The rework is in #11665.

    • The cell. PerryTextField and PerrySecureTextField return the inset cell from cellClass, so textFieldWithString: and labelWithString: build it with the factory setup. The cell swap, the property restore in install_label_cell, and the single-line properties from fix(ui-macos): keep editable TextField single-line (#10155) #10157 are gone.
    • Padding. setPadding on a text field now works as CSS padding does. The cell pads the frames AppKit hands it to draw and to edit, not drawingRectForBounds:. A bezeled or bordered field no longer moves its text by the padding on focus. textfieldSetBackgroundColor paints the field's layer, so the background fills the padding.
    • One line. Without single-line mode, a stock NSTextField keeps pasted and Option-Return newlines, and the field grows taller. So the field keeps the value to one line itself, with the same rules as a web <input> in Chrome. 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.

    The checklist in my last comment holds. native_textfield_cell covers the baseline, one line, and padding in every border style. native_textfield_newlines covers line breaks.

    Single-line mode is working as Apple documents it

    This reproduces with no Perry code: a stock NSTextField with usesSingleLineMode = true and a non-system font jumps on focus, with the same numbers as the issue. Apple's reference for usesSingleLineMode says a single-line cell uses a fixed baseline that depends only on the control size, not on the font. In the idle draw, AppKit gets that baseline from -[NSFont baseFontForSingleLineModeCell:]. It returns the system font for the control size (13pt regular, 11pt small) unless the field already uses the system font. The field editor lays out with the field's own font, so the two disagree.

    Others have hit the same thing and landed on the same fix:

    • Lazarus #39959 and MR !114 (2022) saw the offset with non-system fonts. They turned single-line mode off and removed line breaks in textView:shouldChangeTextInRange:replacementString:.
    • rdar://13813516 (2013) reported the clipping at 21pt and above with single-line mode on.
    • A Stack Overflow question saw the clipping with HelveticaNeue-UltraLight at 24pt and turned single-line mode off.
  3. 18 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions