Skip to content

Add max_lines to :text so a name can stay on one line - #144

Open
asheehan wants to merge 1 commit into
GenericJam:masterfrom
asheehan:max-lines-text
Open

Add max_lines to :text so a name can stay on one line#144
asheehan wants to merge 1 commit into
GenericJam:masterfrom
asheehan:max-lines-text

Conversation

@asheehan

Copy link
Copy Markdown
Contributor

Closes#143.

Summary

A :text node wraps without limit on both platforms; the only single-line text is a :button label, hard-coded to one line on each side. Apps that needed a merchant name beside a non-shrinking amount truncated the string by grapheme count in Elixir, which guesses at a width only native layout knows.

This adds max_lines: pos_integer() on :text. Unset means what it meant before, so no existing tree renders differently.

API

~MOB"""<Row fill_width={true}> <Text text={@merchant} max_lines={1} weight={1} /> <Text text={@amount} /></Row>"""
  • Mob.Renderer forwards a positive integer as "max_lines"; drops nil (so max_lines: if(compact?, do: 1) is safe — a JSON null would reach iOS as NSNull); raises ArgumentError for anything else. Validated in the renderer because it is the one point every construction path (map literal, Mob.UI.text/1, ~MOB) passes through, and each platform would otherwise coerce a bad value into a different limit (Compose rejects maxLines <= 0).
  • Mob.UI.text/1 keeps the prop through its allowlist.
  • Tail truncation only: iOS also has head/middle modes, Compose Text does not; one mode both honour beats a prop that means different things per platform. Rationale in decisions/2026-09-07-max-lines-is-a-native-prop.md.

Platform notes

iOS (this PR):MobNode gains NSInteger maxLines (0 = unset). mob_nif.m adds MOB_PROP_max_lines to the slot enum/names table and reads it NSNumber-guarded in the node builder. MobRootView.swift's .label case applies .lineLimit(n).truncationMode(.tail) through the existing ifLet, so an unset node takes no modifier at all. .button is untouched (still fixed at 1).

Android (companion PR GenericJam/mob_new#62): the Compose renderer lives in the generated MobBridge.kt, so MobText reads the prop there — maxLines = n, overflow = TextOverflow.Ellipsis, falling back to Compose's own defaults (Int.MAX_VALUE, Clip) when absent. An app must be regenerated (or its bridge re-rendered) to pick it up; an older bridge ignores the key and wraps as before. The CHANGELOG entry says so.

Test plan

Fidelity ladder, per AGENTS.md — stopped at rung 2:

  1. Static:mix format --check-formatted, mix credo --strict (ExSlop + Jump checks), mix compile --warnings-as-errors, xcrun clang-format --dry-run -Werror on ios/mob_nif.m, MobNode.h, MobNode.m — all clean. swiftlint was installed but cannot run in this environment (no Xcode SourceKit), so that rung was not run.
  2. Host unit:mix test — 1559 passed, 38 excluded (baseline 1554 + 5 new). New tests: renderer drops nil / raises on 0, -1, 1.0, "1"; Mob.UI.text/1 keeps max_lines; a source-contract test pins the iOS boundary (MobNode.h/.m, the mob_nif.m slot and NSNumber guard, the ifLet + lineLimit in MobRootView.swift) in the style of native_layout_weight_test.exs. Each was checked by reverting its half of the change: all fail. A plain "positive integer passes through" test was deliberately not added — the generic fallback already sent it before this clause existed, so it could not fail on revert.
  3. Simulator / device: not run — I have no Xcode or device pool on this machine. The SwiftUI change is six lines using the same cond ? node.x : nilifLet shape already compiled at the .box sizing site, so I am reasoning about its compile, not claiming it.

Ran with Elixir 1.20.1 / OTP 28.3 (.tool-versions pins 1.20.0-otp-29, which isn't installed here; mix.exs requires ~> 1.19).

Pre-commit adversarial review by a separate agent found no blocking items; its one should-fix (the vacuous pass-through test) is removed. One observation from it worth a follow-up issue rather than a change here: sibling numeric reads in the node builder (letter_spacing, line_height, weight) are if (x) [x doubleValue], so a nil prop for those reaches NSNull unguarded via the generic fallback. max_lines guards for it; the others predate this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_019tx3qSxxz11ijDcPZ9a4rN

A :text node wrapped without limit on both platforms; the only single-line
text was a :button label, hard-coded to one line on each side. Apps that
needed a merchant name beside a non-shrinking amount truncated the string by
grapheme count in Elixir, which guesses at a width only native layout knows.
max_lines: pos_integer() on :text. The renderer forwards it, drops nil so a
conditional prop stays safe, and raises on anything else. iOS applies
.lineLimit(n).truncationMode(.tail) only when set, so an unset node keeps its
default wrap; MobNode carries it as an NSInteger with 0 as unset and the NIF
reads it NSNumber-guarded. Mob.UI.text/1 keeps the prop through its allowlist.
The Android reader lives in the generated MobBridge.kt (mob_new), landing
separately; an older bridge ignores the key.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tx3qSxxz11ijDcPZ9a4rN
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

:text has no max_lines — a single-line name beside an amount cannot be expressed

1 participant

@asheehan