[UI] add line wrapping functionality

This commit is contained in:
Nicolas Guillot
2025-10-02 12:05:37 +02:00
parent 11f57f7c6e
commit c7198ad9af
4 changed files with 567 additions and 39 deletions

64
Sources/SwiftMath/MathRender/MTMathUILabel.swift Executable file → Normal file
View File

@@ -179,7 +179,19 @@ public class MTMathUILabel : MTView {
/** The internal display of the MTMathUILabel. This is for advanced use only. */
public var displayList: MTMathListDisplay? { _displayList }
private var _displayList:MTMathListDisplay?
/** The preferred maximum width (in points) for a multiline label.
Set this property to enable line wrapping based on available width. */
public var preferredMaxLayoutWidth: CGFloat {
set {
_preferredMaxLayoutWidth = newValue
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _preferredMaxLayoutWidth }
}
private var _preferredMaxLayoutWidth: CGFloat = 0
public var currentStyle:MTLineStyle {
switch _labelMode {
case .display: return .display
@@ -241,8 +253,12 @@ public class MTMathUILabel : MTView {
func _layoutSubviews() {
if _mathList != nil {
// Use the effective width for layout
let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width
let availableWidth = effectiveWidth - contentInsets.left - contentInsets.right
// print("Pre list = \(_mathList!)")
_displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle)
_displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: availableWidth)
_displayList!.textColor = textColor
// print("Post list = \(_mathList!)")
var textX = CGFloat(0)
@@ -252,7 +268,7 @@ public class MTMathUILabel : MTView {
case .right: textX = bounds.size.width - _displayList!.width - contentInsets.right
}
let availableHeight = bounds.size.height - contentInsets.bottom - contentInsets.top
// center things vertically
var height = _displayList!.ascent + _displayList!.descent
if height < fontSize/2 {
@@ -268,19 +284,47 @@ public class MTMathUILabel : MTView {
}
func _sizeThatFits(_ size:CGSize) -> CGSize {
guard _mathList != nil else { return size }
var size = size
guard _mathList != nil else {
// No content - return no-intrinsic-size marker
return CGSize(width: -1, height: -1)
}
// Determine the maximum width to use
var maxWidth: CGFloat = 0
if _preferredMaxLayoutWidth > 0 {
maxWidth = _preferredMaxLayoutWidth - contentInsets.left - contentInsets.right
} else if size.width > 0 {
maxWidth = size.width - contentInsets.left - contentInsets.right
}
var displayList:MTMathListDisplay? = nil
displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle)
size.width = displayList!.width + contentInsets.left + contentInsets.right
size.height = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom
return size
displayList = MTTypesetter.createLineForMathList(_mathList, font: font, style: currentStyle, maxWidth: maxWidth)
guard displayList != nil else {
// Failed to create display list
return CGSize(width: -1, height: -1)
}
let resultWidth = displayList!.width + contentInsets.left + contentInsets.right
let resultHeight = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom
return CGSize(width: resultWidth, height: resultHeight)
}
#if os(macOS)
public func sizeThatFits(_ size: CGSize) -> CGSize {
return _sizeThatFits(size)
}
#else
public override func sizeThatFits(_ size: CGSize) -> CGSize {
return _sizeThatFits(size)
}
#endif
#if os(macOS)
func setNeedsDisplay() { self.needsDisplay = true }
func setNeedsLayout() { self.needsLayout = true }
public override var fittingSize: CGSize { _sizeThatFits(CGSizeZero) }
public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) }
override public var isFlipped: Bool { false }
override public func layout() {
self._layoutSubviews()

View File

@@ -362,23 +362,40 @@ class MTTypesetter {
}
var cramped = false
var spaced = false
var maxWidth: CGFloat = 0 // Maximum width for line breaking, 0 means no constraint
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle) -> MTMathListDisplay? {
let finalizedList = mathList?.finalized
// default is not cramped
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false)
// default is not cramped, no width constraint
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: 0)
}
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, maxWidth:CGFloat) -> MTMathListDisplay? {
let finalizedList = mathList?.finalized
// default is not cramped
return self.createLineForMathList(finalizedList, font:font, style:style, cramped:false, maxWidth: maxWidth)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool) -> MTMathListDisplay? {
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false)
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false, maxWidth: 0)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, maxWidth:CGFloat) -> MTMathListDisplay? {
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:false, maxWidth: maxWidth)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) -> MTMathListDisplay? {
return self.createLineForMathList(mathList, font:font, style:style, cramped:cramped, spaced:spaced, maxWidth: 0)
}
// Internal
static func createLineForMathList(_ mathList:MTMathList?, font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool, maxWidth:CGFloat) -> MTMathListDisplay? {
assert(font != nil)
let preprocessedAtoms = self.preprocessMathList(mathList)
let typesetter = MTTypesetter(withFont:font, style:style, cramped:cramped, spaced:spaced)
let typesetter = MTTypesetter(withFont:font, style:style, cramped:cramped, spaced:spaced, maxWidth: maxWidth)
typesetter.createDisplayAtoms(preprocessedAtoms)
let lastAtom = mathList!.atoms.last
let last = lastAtom?.indexRange ?? NSMakeRange(0, 0)
@@ -387,13 +404,14 @@ class MTTypesetter {
}
static var placeholderColor: MTColor { MTColor.blue }
init(withFont font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool) {
init(withFont font:MTFont?, style:MTLineStyle, cramped:Bool, spaced:Bool, maxWidth:CGFloat = 0) {
self.font = font
self.displayAtoms = [MTDisplay]()
self.currentPosition = CGPoint.zero
self.cramped = cramped
self.spaced = spaced
self.maxWidth = maxWidth
self.currentLine = NSMutableAttributedString()
self.currentAtoms = [MTMathAtom]()
self.style = style
@@ -662,22 +680,78 @@ class MTTypesetter {
}
case .accent:
// stash the existing layout
if currentLine.length > 0 {
self.addDisplayLine()
}
// Accent is considered as Ord in rule 16.
self.addInterElementSpace(prevNode, currentType:.ordinary)
atom.type = .ordinary;
let accent = atom as! MTAccent?
let display = self.makeAccent(accent)
displayAtoms.append(display!)
currentPosition.x += display!.width;
// add super scripts || subscripts
if atom.subScript != nil || atom.superScript != nil {
self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0)
if maxWidth > 0 {
// When line wrapping is enabled, render the accent properly but inline
// to avoid premature line flushing
let accent = atom as! MTAccent
// Get the base character from innerList
var baseChar = ""
if let innerList = accent.innerList, !innerList.atoms.isEmpty {
// Convert innerList to string
baseChar = MTMathListBuilder.mathListToString(innerList)
}
// Combine base character with accent to create proper composed character
let accentChar = atom.nucleus
let composedString = baseChar + accentChar
// Normalize to composed form (NFC) to get proper accented character
let normalizedString = composedString.precomposedStringWithCanonicalMapping
// Add inter-element spacing
if prevNode != nil {
let interElementSpace = self.getInterElementSpace(prevNode!.type, right:.ordinary)
if currentLine.length > 0 {
if interElementSpace > 0 {
currentLine.addAttribute(kCTKernAttributeName as NSAttributedString.Key,
value:NSNumber(floatLiteral: interElementSpace),
range:currentLine.mutableString.rangeOfComposedCharacterSequence(at: currentLine.length-1))
}
} else {
currentPosition.x += interElementSpace
}
}
// Add the properly composed accented character
let current = NSAttributedString(string:normalizedString)
currentLine.append(current)
// Check if we should break the line
self.checkAndBreakLine()
// Add to atom list
if currentLineIndexRange.location == NSNotFound {
currentLineIndexRange = atom.indexRange
} else {
currentLineIndexRange.length += atom.indexRange.length
}
currentAtoms.append(atom)
// Treat accent as ordinary for spacing purposes
atom.type = .ordinary
} else {
// Original behavior when no width constraint
// Check if we need to break the line due to width constraints
self.checkAndBreakLine()
// stash the existing layout
if currentLine.length > 0 {
self.addDisplayLine()
}
// Accent is considered as Ord in rule 16.
self.addInterElementSpace(prevNode, currentType:.ordinary)
atom.type = .ordinary;
let accent = atom as! MTAccent?
let display = self.makeAccent(accent)
displayAtoms.append(display!)
currentPosition.x += display!.width;
// add super scripts || subscripts
if atom.subScript != nil || atom.superScript != nil {
self.makeScripts(atom, display:display, index:UInt(atom.indexRange.location), delta:0)
}
}
case .table:
@@ -720,7 +794,57 @@ class MTTypesetter {
} else {
current = NSAttributedString(string:atom.nucleus)
}
currentLine.append(current!)
// Universal line breaking: only for simple atoms (no scripts)
// This works for text, mixed text+math, and simple equations
let isSimpleAtom = (atom.subScript == nil && atom.superScript == nil)
if isSimpleAtom && maxWidth > 0 {
// Measure the current line width
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, attrString.length))
let ctLine = CTLineCreateWithAttributedString(attrString)
let lineWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
if lineWidth > maxWidth {
// Line is too wide - need to find a break point
let currentText = currentLine.string
// Look for the last space before the current position
if let lastSpaceIndex = currentText.lastIndex(of: " ") {
// Split the line at the last space
let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex)
// Create attributed string for the first line (before space)
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))
// Keep track of atoms that belong to the first line
// For simplicity, we'll split atoms at the boundary (this is approximate)
let firstLineAtoms = currentAtoms
// Flush the first line
currentLine = firstLine
currentAtoms = firstLineAtoms
self.addDisplayLine()
// Move down for new line and reset x position
currentPosition.y -= styleFont.fontSize * 1.5
currentPosition.x = 0
// Start the new line with the content after the space
let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex)))
currentLine = NSMutableAttributedString(string: remainingText)
// Reset atom list for new line
currentAtoms = []
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
}
// If no space found, let it overflow (better than breaking mid-word)
}
}
// add the atom to the current range
if currentLineIndexRange.location == NSNotFound {
currentLineIndexRange = atom.indexRange
@@ -767,6 +891,52 @@ class MTTypesetter {
}
}
/// Check if the current line exceeds maxWidth and break if needed
func checkAndBreakLine() {
guard maxWidth > 0 && currentLine.length > 0 else { return }
// Measure the current line width
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, attrString.length))
let ctLine = CTLineCreateWithAttributedString(attrString)
let lineWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
guard lineWidth > maxWidth else { return }
// Line is too wide - need to find a break point
let currentText = currentLine.string
// Look for the last space before the current position
if let lastSpaceIndex = currentText.lastIndex(of: " ") {
// Split the line at the last space
let spaceOffset = currentText.distance(from: currentText.startIndex, to: lastSpaceIndex)
// Create attributed string for the first line (before space)
let firstLine = NSMutableAttributedString(string: String(currentText.prefix(spaceOffset)))
firstLine.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value:styleFont.ctFont as Any, range:NSMakeRange(0, firstLine.length))
// Keep track of atoms that belong to the first line
let firstLineAtoms = currentAtoms
// Flush the first line
currentLine = firstLine
currentAtoms = firstLineAtoms
self.addDisplayLine()
// Move down for new line and reset x position
currentPosition.y -= styleFont.fontSize * 1.5
currentPosition.x = 0
// Start the new line with the content after the space
let remainingText = String(currentText.suffix(from: currentText.index(after: lastSpaceIndex)))
currentLine = NSMutableAttributedString(string: remainingText)
// Reset atom list for new line
currentAtoms = []
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
}
}
@discardableResult
func addDisplayLine() -> MTCTLineDisplay? {
// add the font