From 912d0af9695fce6d1e37637700f72589d1cf4d0e Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Mon, 29 Jun 2026 14:58:31 +0000 Subject: [PATCH 1/4] Generated with Hive: Replace boundingRect with layoutManager-based height measurement for markdown messages --- .../Helpers/ChatHelper.swift | 22 ++- .../ChatHelperMeasuredTextHeightTests.swift | 176 ++++++++++++++++++ 2 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 com.stakwork.sphinx.desktopTests/ChatHelperMeasuredTextHeightTests.swift diff --git a/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift b/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift index 9058464e..77c3a946 100644 --- a/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift +++ b/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift @@ -828,11 +828,25 @@ class ChatHelper { let kLabelHorizontalMargins: CGFloat = labelHorizontalMargins ?? 32.0 let kLabelVerticalMargins: CGFloat = labelVerticalMargins ?? 32.0 - let textHeight = attributedString.boundingRect( - with: NSSize(width: width - kLabelHorizontalMargins, height: CGFLOAT_MAX), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ).height + kLabelVerticalMargins + let measuredWidth = width - kLabelHorizontalMargins + let textHeight = ChatHelper.measuredTextHeight(for: attributedString, width: measuredWidth) + kLabelVerticalMargins return textHeight } + + /// Measures attributed string height using the same NSLayoutManager stack + /// that MessageTextField uses for rendering — lineFragmentPadding = 0. + public static func measuredTextHeight(for attributedString: NSAttributedString, width: CGFloat) -> CGFloat { + let textStorage = NSTextStorage(attributedString: attributedString) + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer( + containerSize: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + ) + textContainer.lineFragmentPadding = 0 + textContainer.maximumNumberOfLines = 0 + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: textContainer) + return ceil(layoutManager.usedRect(for: textContainer).height) + } } diff --git a/com.stakwork.sphinx.desktopTests/ChatHelperMeasuredTextHeightTests.swift b/com.stakwork.sphinx.desktopTests/ChatHelperMeasuredTextHeightTests.swift new file mode 100644 index 00000000..3678d439 --- /dev/null +++ b/com.stakwork.sphinx.desktopTests/ChatHelperMeasuredTextHeightTests.swift @@ -0,0 +1,176 @@ +// +// ChatHelperMeasuredTextHeightTests.swift +// com.stakwork.sphinx.desktopTests +// +// Unit tests for ChatHelper.measuredTextHeight(for:width:) +// + +import XCTest +@testable import com_stakwork_sphinx_desktop + +class ChatHelperMeasuredTextHeightTests: XCTestCase { + + // MARK: - Fixtures + + /// Long markdown-like formatted message with headings, bullets, blank lines, and emoji + let markdownFixture1 = """ + # Getting Started 🚀 + + Here's a summary of the key features: + + - **Performance**: Optimized for speed + - **Reliability**: 99.9% uptime guaranteed + - **Security**: End-to-end encrypted + + ## Next Steps + + 1. Set up your workspace + 2. Invite your team members + 3. Configure integrations + + Let me know if you have any questions! 🎉 + """ + + let markdownFixture2 = """ + ## Project Status Update + + The following tasks have been completed: + + - ✅ Initial architecture design + - ✅ Database schema finalized + - ⏳ API integration in progress + - ❌ Frontend not started + + ### Blockers + + There are currently **2 blockers** that need attention: + + 1. Missing API credentials for the payment gateway + 2. Unclear requirements for the reporting module + + Please review and provide feedback at your earliest convenience. + """ + + let markdownFixture3 = """ + Hello! 👋 + + I've analyzed your request and here's what I found: + + - The issue is caused by an incorrect height calculation + - The fix involves replacing `boundingRect` with `NSLayoutManager` + - This affects all formatted messages with multiple paragraphs + + > **Note:** This change is backward compatible and requires no migration. + + Feel free to ask if you need more details! 😊 + """ + + let singleLineText = "Hello, world!" + let plainText = "This is a plain text message without any markdown formatting or special characters." + let linkHeavyText = "Check out https://sphinx.chat and https://stakwork.com for more details about the platform and its features." + let emojiOnlyText = "🎉🚀✅❌⏳💡🔥" + + let testWidth: CGFloat = 400.0 + + // MARK: - Helper: boundingRect reference (old method) + + private func boundingRectHeight(for string: NSAttributedString, width: CGFloat) -> CGFloat { + return string.boundingRect( + with: NSSize(width: width, height: CGFLOAT_MAX), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).height + } + + private func makeAttributedString(_ text: String) -> NSAttributedString { + return NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: 14) + ]) + } + + // MARK: - Tests: Markdown fixtures (over-reporting fix) + + func testMarkdownFixture1_measuredLessThanBoundingRect() { + let attr = makeAttributedString(markdownFixture1) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + let bounding = boundingRectHeight(for: attr, width: testWidth) + XCTAssertLessThan(measured, bounding, + "measuredTextHeight should be strictly less than boundingRect for multi-block markdown (fixture 1)") + } + + func testMarkdownFixture2_measuredLessThanBoundingRect() { + let attr = makeAttributedString(markdownFixture2) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + let bounding = boundingRectHeight(for: attr, width: testWidth) + XCTAssertLessThan(measured, bounding, + "measuredTextHeight should be strictly less than boundingRect for multi-block markdown (fixture 2)") + } + + func testMarkdownFixture3_measuredLessThanBoundingRect() { + let attr = makeAttributedString(markdownFixture3) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + let bounding = boundingRectHeight(for: attr, width: testWidth) + XCTAssertLessThan(measured, bounding, + "measuredTextHeight should be strictly less than boundingRect for multi-block markdown (fixture 3)") + } + + func testMarkdownFixture1_matchesLayoutManagerUsedRect() { + let attr = makeAttributedString(markdownFixture1) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + + // Reproduce the same layout stack to get the reference usedRect + let textStorage = NSTextStorage(attributedString: attr) + let layoutManager = NSLayoutManager() + let container = NSTextContainer(containerSize: NSSize(width: testWidth, height: CGFloat.greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.maximumNumberOfLines = 0 + layoutManager.addTextContainer(container) + textStorage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: container) + let expected = ceil(layoutManager.usedRect(for: container).height) + + XCTAssertEqual(measured, expected, accuracy: 1.0, + "measuredTextHeight must match layoutManager.usedRect height within ±1pt") + } + + // MARK: - Regression tests: no under-reporting + + func testSingleLine_notUnderReported() { + let attr = makeAttributedString(singleLineText) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + XCTAssertGreaterThan(measured, 0, "Single-line message must have positive height") + // Should be at least one line height (system font 14pt ≈ 17pt line height) + XCTAssertGreaterThanOrEqual(measured, 10, "Single-line height must not be under-reported") + } + + func testPlainText_notUnderReported() { + let attr = makeAttributedString(plainText) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + XCTAssertGreaterThan(measured, 0, "Plain text message must have positive height") + } + + func testLinkHeavyText_notUnderReported() { + let attr = makeAttributedString(linkHeavyText) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + XCTAssertGreaterThan(measured, 0, "Link-heavy message must have positive height") + } + + func testEmojiOnly_notUnderReported() { + let attr = makeAttributedString(emojiOnlyText) + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + XCTAssertGreaterThan(measured, 0, "Emoji-only message must have positive height") + } + + // MARK: - Edge cases + + func testEmptyString_returnsZero() { + let attr = makeAttributedString("") + let measured = ChatHelper.measuredTextHeight(for: attr, width: testWidth) + XCTAssertEqual(measured, 0, accuracy: 1.0, "Empty string should return ~0 height") + } + + func testNarrowWidth_stillPositive() { + let attr = makeAttributedString(markdownFixture1) + let measured = ChatHelper.measuredTextHeight(for: attr, width: 100) + XCTAssertGreaterThan(measured, 0, "Narrow width must still produce positive height") + } +} From f457ba01bbcfbd2b41b94734b8cf9c8c656be48e Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Mon, 29 Jun 2026 15:23:45 +0000 Subject: [PATCH 2/4] Generated with Hive: Set usesFontLeading to false in measuredTextHeight to match NSTextFieldCell layout --- com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift b/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift index 77c3a946..5d744712 100644 --- a/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift +++ b/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift @@ -835,10 +835,12 @@ class ChatHelper { } /// Measures attributed string height using the same NSLayoutManager stack - /// that MessageTextField uses for rendering — lineFragmentPadding = 0. + /// that MessageTextField uses for rendering — lineFragmentPadding = 0, usesFontLeading = false. + /// NSTextFieldCell draws without font leading, so we match that here. public static func measuredTextHeight(for attributedString: NSAttributedString, width: CGFloat) -> CGFloat { let textStorage = NSTextStorage(attributedString: attributedString) let layoutManager = NSLayoutManager() + layoutManager.usesFontLeading = false let textContainer = NSTextContainer( containerSize: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) ) From e90d3901d625a1e7dd851ba5aa462e8e1c807b0b Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Mon, 29 Jun 2026 15:45:02 +0000 Subject: [PATCH 3/4] Generated with Hive: Fix message row height calculation to prevent text cutoff on macOS --- .../Custom Classes/PaddedTextFieldCell.swift | 2 +- .../Helpers/ChatHelper.swift | 24 ++++--------------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/com.stakwork.sphinx.desktop/Custom Classes/PaddedTextFieldCell.swift b/com.stakwork.sphinx.desktop/Custom Classes/PaddedTextFieldCell.swift index 1c2c0ef6..7e25f4bb 100644 --- a/com.stakwork.sphinx.desktop/Custom Classes/PaddedTextFieldCell.swift +++ b/com.stakwork.sphinx.desktop/Custom Classes/PaddedTextFieldCell.swift @@ -123,7 +123,7 @@ class PaddedTextField: CCTextField { override var intrinsicContentSize: NSSize { var size = super.intrinsicContentSize size.width += contentPadding.left + contentPadding.right - size.height += contentPadding.top + contentPadding.bottom + 40 + size.height += contentPadding.top + contentPadding.bottom return size } diff --git a/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift b/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift index 5d744712..9058464e 100644 --- a/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift +++ b/com.stakwork.sphinx.desktop/Helpers/ChatHelper.swift @@ -828,27 +828,11 @@ class ChatHelper { let kLabelHorizontalMargins: CGFloat = labelHorizontalMargins ?? 32.0 let kLabelVerticalMargins: CGFloat = labelVerticalMargins ?? 32.0 - let measuredWidth = width - kLabelHorizontalMargins - let textHeight = ChatHelper.measuredTextHeight(for: attributedString, width: measuredWidth) + kLabelVerticalMargins + let textHeight = attributedString.boundingRect( + with: NSSize(width: width - kLabelHorizontalMargins, height: CGFLOAT_MAX), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).height + kLabelVerticalMargins return textHeight } - - /// Measures attributed string height using the same NSLayoutManager stack - /// that MessageTextField uses for rendering — lineFragmentPadding = 0, usesFontLeading = false. - /// NSTextFieldCell draws without font leading, so we match that here. - public static func measuredTextHeight(for attributedString: NSAttributedString, width: CGFloat) -> CGFloat { - let textStorage = NSTextStorage(attributedString: attributedString) - let layoutManager = NSLayoutManager() - layoutManager.usesFontLeading = false - let textContainer = NSTextContainer( - containerSize: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) - ) - textContainer.lineFragmentPadding = 0 - textContainer.maximumNumberOfLines = 0 - layoutManager.addTextContainer(textContainer) - textStorage.addLayoutManager(layoutManager) - layoutManager.ensureLayout(for: textContainer) - return ceil(layoutManager.usedRect(for: textContainer).height) - } } From c718a92c23b29767d2967a61301cbf57d86a1749 Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Mon, 29 Jun 2026 16:07:46 +0000 Subject: [PATCH 4/4] Generated with Hive: Remove extra newline spacing after list items in MarkdownRenderer to fix message bubble height --- .../Scenes/Dashboard/Workspaces/MarkdownRenderer.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Workspaces/MarkdownRenderer.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Workspaces/MarkdownRenderer.swift index 4abc3b1e..543d88c3 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Workspaces/MarkdownRenderer.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Workspaces/MarkdownRenderer.swift @@ -244,8 +244,6 @@ final class MarkdownRenderer { let content = renderInline(text, font: style.baseFont, color: style.textColor) let result = NSMutableAttributedString(attributedString: prefix) result.append(content) - // Extra spacing below each list item - result.append(NSAttributedString(string: "\n", attributes: [.font: style.baseFont])) return result } @@ -264,14 +262,10 @@ final class MarkdownRenderer { range: NSRange(location: 0, length: mutable.length)) let result = NSMutableAttributedString(attributedString: prefix) result.append(mutable) - // Extra spacing below each task item - result.append(NSAttributedString(string: "\n", attributes: [.font: style.baseFont])) return result } let result = NSMutableAttributedString(attributedString: prefix) result.append(content) - // Extra spacing below each task item - result.append(NSAttributedString(string: "\n", attributes: [.font: style.baseFont])) return result }