Skip to content

Commit df90450

Browse files
authored
Keep side reply previews inside narrow chat rows (#84)
* Keep side replies inside narrow chat rows * Add native before and after reply captures
1 parent cdf97a0 commit df90450

5 files changed

Lines changed: 153 additions & 6 deletions

File tree

‎packages/ChatUI/Sources/ChatUI/ChatRowItem.swift‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -446,8 +446,8 @@ public class ChatRowItem: TableRowItem {
446446
height += additional
447447
}
448448

449-
if replyModel?.isSideAccessory == true {
450-
height = max(48, height)
449+
if let replyModel, replyModel.isSideAccessory {
450+
height = max(48, height, replyOffset + replyModel.size.height + defaultContentTopOffset)
451451
}
452452
}
453453
if let _ = commentsBubbleData {
@@ -3191,10 +3191,20 @@ public class ChatRowItem: TableRowItem {
31913191
replyModel.measureSize(fill_size, sizeToFit: false)
31923192
}
31933193
} else {
3194-
if _contentSize.width == 0 {
3195-
replyModel.measureSize(200, sizeToFit: true)
3196-
} else {
3197-
replyModel.measureSize(_contentSize.width - bubbleContentInset * 2, sizeToFit: true)
3194+
let bubbleFrame = ChatRowGeometry.bubbleFrame(.init(
3195+
rowWidth: width,
3196+
layoutFrame: NSMakeRect(defLeftInset, 0, bubbleFrameWidth, 0),
3197+
leftInset: leftInset,
3198+
isIncoming: isIncoming,
3199+
isCenteredThread: chatInteraction.mode.isThreadMode && chatInteraction.chatLocation.threadMsgId == message?.id
3200+
))
3201+
let contentFrame = ChatRowGeometry.contentFrame(.init(item: self, bubbleFrame: bubbleFrame))
3202+
let sideFrame = ChatRowGeometry.sideAccessoryFrame(.init(item: self, size: .zero, offset: 0, contentFrame: contentFrame))
3203+
let availableWidth = max(1, isIncoming ? width - rightInset - sideFrame.minX : sideFrame.maxX - leftInset)
3204+
let preferredWidth = _contentSize.width == 0 ? 200 : _contentSize.width - bubbleContentInset * 2
3205+
replyModel.measureSize(min(preferredWidth, availableWidth), sizeToFit: true)
3206+
if replyModel.size.width > availableWidth {
3207+
replyModel.measureSize(availableWidth, sizeToFit: false)
31983208
}
31993209
}
32003210
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import Cocoa
2+
3+
final class Accessory {
4+
var isSideAccessory = true
5+
var size = NSSize.zero
6+
var width: CGFloat = 0
7+
var longQuote = false
8+
func measureSize(_ width: CGFloat, sizeToFit: Bool) {
9+
self.width = width
10+
let lines = longQuote ? min(8, ceil(700 / max(1, width - 14))) : 1
11+
size = NSSize(width: sizeToFit ? min(180, max(1, width)) + 12 : width, height: 22 + 16 * lines)
12+
}
13+
}
14+
struct Mode { var isThreadMode = false }
15+
struct Location { var threadMsgId: Int? }
16+
struct Interaction { let mode = Mode(); let chatLocation = Location() }
17+
struct Message { let id: Int }
18+
final class ChatRowItem {
19+
var width: CGFloat = 320
20+
var leftInset: CGFloat = 13
21+
var rightInset: CGFloat = 12
22+
var defLeftInset: CGFloat = 20
23+
var isIncoming = false
24+
var isBubbled = true
25+
var hasBubble = false
26+
var isBubbleFullFilled = true
27+
var isSharable = false
28+
var hasSource = false
29+
var commentsBubbleDataOverlay: Int?
30+
var elementsContentInset: CGFloat = 0
31+
var bubbleContentInset: CGFloat = 0
32+
var additionBubbleInset: CGFloat = 6
33+
var bubbleDefaultInnerInset: CGFloat { bubbleContentInset * 2 + additionBubbleInset }
34+
var _contentSize = NSSize(width: 160, height: 80)
35+
var contentSize: NSSize { _contentSize }
36+
var contentOffset: NSPoint { NSPoint(x: defLeftInset, y: 2) }
37+
var bubbleFrameWidth: CGFloat { _contentSize.width + additionBubbleInset }
38+
var max_reply_size_width: CGFloat { _contentSize.width }
39+
var additionalLineForDateInBubbleState: CGFloat?
40+
var defaultContentTopOffset: CGFloat = 2
41+
var replyOffset: CGFloat = 5
42+
var replyModel: Accessory? = Accessory()
43+
let chatInteraction = Interaction()
44+
var message: Message?
45+
func measureReply() {
46+
let cachedBlockWidth = width
47+
if let replyModel = replyModel { __MEASURE__ }
48+
}
49+
var height: CGFloat {
50+
var height = _contentSize.height + defaultContentTopOffset * 2
51+
__HEIGHT__
52+
return height
53+
}
54+
var replyFrame: NSRect {
55+
let bubble = ChatRowGeometry.bubbleFrame(.init(rowWidth: width, layoutFrame: NSRect(x: defLeftInset, y: 0, width: bubbleFrameWidth, height: 0), leftInset: leftInset, isIncoming: isIncoming, isCenteredThread: false))
56+
let content = ChatRowGeometry.contentFrame(.init(item: self, bubbleFrame: bubble))
57+
return ChatRowGeometry.sideAccessoryFrame(.init(item: self, size: replyModel!.size, offset: replyOffset, contentFrame: content))
58+
}
59+
}
60+
var checks = 0
61+
var failures = 0
62+
func expect(_ condition: @autoclosure () -> Bool, _ name: String) {
63+
checks += 1
64+
if !condition() { failures += 1; print("FAIL: \(name)") }
65+
}
66+
for width in [CGFloat(300), 320, 350, 400, 640, 1280] {
67+
for incoming in [false, true] {
68+
for sideAction in [false, true] {
69+
for contentWidth in [CGFloat(80), 160, 200] {
70+
for longQuote in [false, true] {
71+
let row = ChatRowItem()
72+
row.width = width
73+
row.isIncoming = incoming
74+
row.isSharable = sideAction
75+
row._contentSize.width = contentWidth
76+
row.replyModel!.longQuote = longQuote
77+
row.measureReply()
78+
let frame = row.replyFrame
79+
let tag = "width=\(width) incoming=\(incoming) action=\(sideAction) content=\(contentWidth) quote=\(longQuote)"
80+
expect(frame.minX >= row.leftInset, "reply keeps left margin: \(tag)")
81+
expect(frame.maxX <= width - row.rightInset, "reply keeps right margin: \(tag)")
82+
expect(frame.maxY <= row.height - row.defaultContentTopOffset, "entire quoted reply fits row height: \(tag)")
83+
}
84+
}
85+
}
86+
}
87+
}
88+
let row = ChatRowItem()
89+
row.width = 1280
90+
row.measureReply()
91+
expect(row.replyModel!.size.width == 172, "wide layout keeps intrinsic side reply sizing")
92+
row.width = 320
93+
row.measureReply()
94+
expect(row.replyFrame.minX >= row.leftInset, "resizing narrows existing reply")
95+
row.width = 1280
96+
row.measureReply()
97+
expect(row.replyModel!.size.width == 172, "resizing restores intrinsic side reply width")
98+
print("\(checks - failures)/\(checks) side reply layout checks passed")
99+
exit(failures == 0 ? 0 : 1)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import argparse
2+
from pathlib import Path
3+
import subprocess
4+
import tempfile
5+
6+
root = Path(__file__).resolve().parents[3]
7+
parser = argparse.ArgumentParser()
8+
parser.add_argument('--source-root', type=Path, default=root)
9+
args = parser.parse_args()
10+
source = args.source_root / 'packages/ChatUI/Sources/ChatUI'
11+
12+
def block(text, marker):
13+
start = text.index(marker) + len(marker)
14+
end = start
15+
depth = 1
16+
while depth:
17+
depth += (text[end] == '{') - (text[end] == '}')
18+
end += 1
19+
return text[start:end - 1]
20+
21+
row = (source / 'ChatRowItem.swift').read_text()
22+
measure = block(row, '} else if let replyModel = replyModel {')
23+
height_start = row.index(' if isBubbled {\n if let additional = additionalLineForDateInBubbleState')
24+
height = row[height_start:row.index(' if let _ = commentsBubbleData', height_start)]
25+
adapter = (source / 'ChatRowGeometry+Item.swift').read_text()
26+
content = 'extension ChatRowGeometry.Content {' + block(adapter, 'extension ChatRowGeometry.Content {') + '}\n'
27+
adapter = (source / 'ChatRowAccessoryGeometry+Item.swift').read_text()
28+
side = 'extension ChatRowGeometry.SideAccessory {' + block(adapter, 'extension ChatRowGeometry.SideAccessory {') + '}\n'
29+
fixture = Path(__file__).with_name('SideReplyLayoutTests.swift').read_text().replace('__MEASURE__', measure).replace('__HEIGHT__', height)
30+
with tempfile.TemporaryDirectory(prefix='side-reply-layout-') as temp:
31+
temp = Path(temp)
32+
for name in ['ChatRowGeometry.swift', 'ChatRowAccessoryGeometry.swift']:
33+
(temp / name).write_bytes((source / name).read_bytes())
34+
(temp / 'Adapters.swift').write_text('import Cocoa\n' + content + side)
35+
(temp / 'main.swift').write_text(fixture)
36+
binary = temp / 'tests'
37+
subprocess.run(['swiftc', str(temp / 'ChatRowGeometry.swift'), str(temp / 'ChatRowAccessoryGeometry.swift'), str(temp / 'Adapters.swift'), str(temp / 'main.swift'), '-o', str(binary)], check=True)
38+
raise SystemExit(subprocess.run([str(binary)]).returncode)
172 KB
Loading
164 KB
Loading

0 commit comments

Comments
 (0)