From 76e9c331dd4d9602dbfa5b3207e6bc62e76d32af Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Sun, 12 Oct 2025 12:00:24 +0800 Subject: [PATCH 1/5] attributed string Signed-off-by: Bugen Zhao --- .../zh-Hans.lproj/Localizable.strings | 1 + app/Shared/Utilities/ContentCombiner.swift | 53 ++++++++++++++----- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index f2fe1daf..b4fe9c75 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -45,6 +45,7 @@ "Select a Forum in Sidebar" = "在边栏中选择一个版块"; "Select a Topic" = "选择一个话题"; "Post by" = "发表自"; +"Post by " = "发表自 "; "Filters" = "过滤"; "Hot Replies" = "热门回复"; "Collapsed Content" = "折叠的内容"; diff --git a/app/Shared/Utilities/ContentCombiner.swift b/app/Shared/Utilities/ContentCombiner.swift index 99f85f12..d48a0573 100644 --- a/app/Shared/Utilities/ContentCombiner.swift +++ b/app/Shared/Utilities/ContentCombiner.swift @@ -13,6 +13,7 @@ import SwiftUIX class ContentCombiner { enum Subview { case text(Text) + case string(AttributedString) case breakline case other(AnyView) } @@ -145,10 +146,10 @@ class ContentCombiner { self.postDate = postDate } - private func styledText(_ text: Text, overridenFont: Font? = nil, overridenColor: Color? = nil) -> Text { - var text: Text = text - .font(overridenFont ?? font) - .foregroundColor(overridenColor ?? color) + private func styledText(_ text: Text) -> Text { + var text = text + .font(font) + .foregroundColor(color) if otherStyles.contains(.underline) { text = text.underline() @@ -160,6 +161,21 @@ class ContentCombiner { return text } + private func styledString(_ string: AttributedString) -> AttributedString { + var string = string + string.font = font + string.foregroundColor = color + + if otherStyles.contains(.underline) { + string.underlineStyle = .single + } + if otherStyles.contains(.strikethrough) { + string.strikethroughStyle = .single + } + + return string + } + private func append(_ view: some View) { let subview: Subview @@ -175,6 +191,15 @@ class ContentCombiner { subviews.append(subview) } + private func append(_ string: AttributedString) { + let styledString = styledString(string) + subviews.append(.string(styledString)) + } + + private func append(_ string: String) { + append(AttributedString(string)) + } + private func append(_ subview: Subview) { subviews.append(subview) } @@ -195,6 +220,8 @@ class ContentCombiner { switch subview { case let .text(text): textBuffer = (textBuffer ?? Text("")) + text + case let .string(string): + textBuffer = (textBuffer ?? Text("")) + Text(string) case .breakline: if textBuffer != nil { textBuffer = textBuffer! + Text("\n") @@ -217,7 +244,7 @@ class ContentCombiner { .fixedSize(horizontal: false, vertical: true) } } - return .other(AnyView(stack)) + return .other(stack.eraseToAnyView()) } } @@ -226,6 +253,8 @@ class ContentCombiner { switch build() { case let .text(text): text + case let .string(string): + Text(string) case .breakline: // not reached EmptyView() @@ -254,17 +283,15 @@ class ContentCombiner { } private func visit(plain: Span.Plain) { - let text: Text - var plain = plain plain.text = plain.text.replacingOccurrences(of: "[*]", with: "→ ") - if plain.text == "Post by " { - text = Text("Post by") + Text(" ") + let string = if plain.text == "Post by " { + AttributedString(localized: "Post by ") } else { - text = Text(plain.text) + AttributedString(plain.text) } - append(text) + append(string) } private func visit(divider: Span.Tagged) { @@ -454,13 +481,13 @@ class ContentCombiner { private func visit(pid: Span.Tagged) { let combiner = ContentCombiner(parent: self, font: { $0?.bold() }) - combiner.append(Text("Post")) + combiner.append("Post") if pid.attributes.count > 2 { let id = PostId.with { $0.pid = pid.attributes[0] $0.tid = pid.attributes[1] } - combiner.append(Text(" #\(id.pid) ")) + combiner.append(" #\(id.pid) ") replyTo = id } append(combiner.build()) From 2488ab1530920bf3822153edcd816107db53d456 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Sun, 12 Oct 2025 12:30:12 +0800 Subject: [PATCH 2/5] use textview to enable selection Signed-off-by: Bugen Zhao --- app/.package.resolved | 10 ++++- app/Project.swift | 2 + app/Shared/Utilities/ContentCombiner.swift | 41 +++++++++++-------- app/Shared/Views/PostRowView.swift | 2 +- .../Views/ShortMessagePostRowView.swift | 2 +- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/app/.package.resolved b/app/.package.resolved index d63f0e5d..f0ee89ea 100644 --- a/app/.package.resolved +++ b/app/.package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "96354e940245cafaa647d9732b8970714f1940796fb057c444b7a34783652031", + "originHash" : "aab12e14249b1bb54284caab18d383aad59b04cef08757f6f257b61a1bf6f932", "pins" : [ { "identity" : "alerttoast", @@ -72,6 +72,14 @@ "version" : "1.5.0" } }, + { + "identity" : "richtext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LiYanan2004/RichText.git", + "state" : { + "revision" : "c569468fba2df5d76badb034d28858459cc37bd7" + } + }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", diff --git a/app/Project.swift b/app/Project.swift index c2538a5c..40ae5b37 100644 --- a/app/Project.swift +++ b/app/Project.swift @@ -21,6 +21,7 @@ let project = Project( .remote(url: "https://github.com/gh123man/LazyPager", requirement: .exact("1.1.13")), .remote(url: "https://github.com/tevelee/SwiftUI-Flow", requirement: .exact("3.1.0")), .remote(url: "https://github.com/SvenTiigi/WhatsNewKit.git", requirement: .exact("2.2.1")), + .remote(url: "https://github.com/LiYanan2004/RichText.git", requirement: .revision("c569468fba2df5d76badb034d28858459cc37bd7")), ], targets: [ // iOS App Target @@ -58,6 +59,7 @@ let project = Project( .package(product: "LazyPager"), .package(product: "Flow"), .package(product: "WhatsNewKit"), + .package(product: "RichText"), .xcframework(path: "../out/logic-ios.xcframework"), ], settings: .settings( diff --git a/app/Shared/Utilities/ContentCombiner.swift b/app/Shared/Utilities/ContentCombiner.swift index d48a0573..c2dc6a99 100644 --- a/app/Shared/Utilities/ContentCombiner.swift +++ b/app/Shared/Utilities/ContentCombiner.swift @@ -7,12 +7,19 @@ import Colorful import Foundation +import RichText import SwiftUI -import SwiftUIX + +private extension TextContent { + static func text(_ text: Text) -> Self { + let fragments = TextContentBuilder.buildExpression(text).fragments + return Self(fragments) + } +} class ContentCombiner { enum Subview { - case text(Text) + case text(TextContent) case string(AttributedString) case breakline case other(AnyView) @@ -181,7 +188,7 @@ class ContentCombiner { if view is Text { let text = styledText(view as! Text) - subview = Subview.text(text) + subview = Subview.text(.text(text)) } else if view is AnyView { subview = Subview.other(view as! AnyView) } else { @@ -205,39 +212,39 @@ class ContentCombiner { } private func build() -> Subview { - var textBuffer: Text? + var textBuffer = TextContent() var results = [AnyView]() - func tryAppendTextBuffer() { - if let tb = textBuffer { - let view = tb.eraseToAnyView() + func tryFinishTextBuffer() { + if !textBuffer.fragments.isEmpty { + let view = TextView { textBuffer }.eraseToAnyView() results.append(view) - textBuffer = nil + textBuffer = TextContent() } } for subview in subviews { switch subview { case let .text(text): - textBuffer = (textBuffer ?? Text("")) + text + textBuffer += text case let .string(string): - textBuffer = (textBuffer ?? Text("")) + Text(string) + textBuffer += .init(.attributedString(string)) case .breakline: - if textBuffer != nil { - textBuffer = textBuffer! + Text("\n") + if !textBuffer.fragments.isEmpty { + textBuffer += .init(.string("\n")) } case let .other(view): - tryAppendTextBuffer() + tryFinishTextBuffer() results.append(view) } } if results.isEmpty { // text-only view - return .text(textBuffer ?? Text("")) + return .text(textBuffer) } else { // complex view - tryAppendTextBuffer() + tryFinishTextBuffer() let stack = VStack(alignment: alignment, spacing: 8) { ForEach(results.indices, id: \.self) { index in results[index] @@ -252,7 +259,7 @@ class ContentCombiner { func buildView() -> some View { switch build() { case let .text(text): - text + TextView { text } case let .string(string): Text(string) case .breakline: @@ -309,7 +316,7 @@ class ContentCombiner { let name = sticker.name.replacingOccurrences(of: ":", with: "|") let view: Text? - if let image = AppKitOrUIKitImage(named: name) { + if let image = PlatformImage(named: name) { let renderingMode: Image.TemplateRenderingMode = name.starts(with: "ac") || name.starts(with: "a2") ? .template : .original view = Text( diff --git a/app/Shared/Views/PostRowView.swift b/app/Shared/Views/PostRowView.swift index 7ea24a69..4588c7ca 100644 --- a/app/Shared/Views/PostRowView.swift +++ b/app/Shared/Views/PostRowView.swift @@ -198,7 +198,7 @@ struct PostRowView: View { signature }.padding(.vertical, 2) .fixedSize(horizontal: false, vertical: true) - .contextMenu { menu } + // .contextMenu { menu } // interferes with text selection #if os(iOS) .listRowBackground(action?.scrollToPid == post.id.pid ? Color.tertiarySystemBackground : nil) #endif diff --git a/app/Shared/Views/ShortMessagePostRowView.swift b/app/Shared/Views/ShortMessagePostRowView.swift index dd8fafdc..717dd22a 100644 --- a/app/Shared/Views/ShortMessagePostRowView.swift +++ b/app/Shared/Views/ShortMessagePostRowView.swift @@ -87,7 +87,7 @@ struct ShortMessagePostRowView: View { }.padding(.vertical, 2) .fixedSize(horizontal: false, vertical: true) #if os(macOS) - .contextMenu { menu } + // .contextMenu { menu } // interferes with text selection #endif } } From 44740690a1b855ea25dcf5efa7bd0527773bad54 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Sun, 12 Oct 2025 12:42:55 +0800 Subject: [PATCH 3/5] cleanup Signed-off-by: Bugen Zhao --- app/Shared/Utilities/ContentCombiner.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/Shared/Utilities/ContentCombiner.swift b/app/Shared/Utilities/ContentCombiner.swift index c2dc6a99..adf48e24 100644 --- a/app/Shared/Utilities/ContentCombiner.swift +++ b/app/Shared/Utilities/ContentCombiner.swift @@ -11,16 +11,19 @@ import RichText import SwiftUI private extension TextContent { + // Prefer attributed string for better performance. static func text(_ text: Text) -> Self { - let fragments = TextContentBuilder.buildExpression(text).fragments - return Self(fragments) + TextContentBuilder.buildExpression(text) + } + + static func attributedString(_ string: AttributedString) -> Self { + Self(.attributedString(string)) } } class ContentCombiner { enum Subview { case text(TextContent) - case string(AttributedString) case breakline case other(AnyView) } @@ -200,7 +203,7 @@ class ContentCombiner { private func append(_ string: AttributedString) { let styledString = styledString(string) - subviews.append(.string(styledString)) + subviews.append(.text(.attributedString(styledString))) } private func append(_ string: String) { @@ -227,8 +230,6 @@ class ContentCombiner { switch subview { case let .text(text): textBuffer += text - case let .string(string): - textBuffer += .init(.attributedString(string)) case .breakline: if !textBuffer.fragments.isEmpty { textBuffer += .init(.string("\n")) @@ -260,8 +261,6 @@ class ContentCombiner { switch build() { case let .text(text): TextView { text } - case let .string(string): - Text(string) case .breakline: // not reached EmptyView() From ad6c39f058a73fdf3eabf4e36a562caa37cfc091 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Sun, 12 Oct 2025 13:08:33 +0800 Subject: [PATCH 4/5] reduce text usage Signed-off-by: Bugen Zhao --- app/Shared/Utilities/ContentCombiner.swift | 52 ++++++++-------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/app/Shared/Utilities/ContentCombiner.swift b/app/Shared/Utilities/ContentCombiner.swift index adf48e24..14a5f9b0 100644 --- a/app/Shared/Utilities/ContentCombiner.swift +++ b/app/Shared/Utilities/ContentCombiner.swift @@ -157,22 +157,11 @@ class ContentCombiner { } private func styledText(_ text: Text) -> Text { - var text = text - .font(font) - .foregroundColor(color) - - if otherStyles.contains(.underline) { - text = text.underline() - } - if otherStyles.contains(.strikethrough) { - text = text.strikethrough() - } - - return text + Text("⚠️\(text)⚠️") } - private func styledString(_ string: AttributedString) -> AttributedString { - var string = string + private func styledString(_ string: String) -> AttributedString { + var string = AttributedString(string) string.font = font string.foregroundColor = color @@ -186,6 +175,10 @@ class ContentCombiner { return string } + private func append(symbol: Image) { + subviews.append(.text(.text(Text(symbol)))) + } + private func append(_ view: some View) { let subview: Subview @@ -201,15 +194,11 @@ class ContentCombiner { subviews.append(subview) } - private func append(_ string: AttributedString) { + private func append(_ string: String) { let styledString = styledString(string) subviews.append(.text(.attributedString(styledString))) } - private func append(_ string: String) { - append(AttributedString(string)) - } - private func append(_ subview: Subview) { subviews.append(subview) } @@ -293,9 +282,9 @@ class ContentCombiner { plain.text = plain.text.replacingOccurrences(of: "[*]", with: "→ ") let string = if plain.text == "Post by " { - AttributedString(localized: "Post by ") + "Post by ".localized } else { - AttributedString(plain.text) + plain.text } append(string) } @@ -314,19 +303,14 @@ class ContentCombiner { private func visit(sticker: Span.Sticker) { let name = sticker.name.replacingOccurrences(of: ":", with: "|") - let view: Text? if let image = PlatformImage(named: name) { let renderingMode: Image.TemplateRenderingMode = name.starts(with: "ac") || name.starts(with: "a2") ? .template : .original - view = Text( - Image(image: image) - .renderingMode(renderingMode) - ) + append(symbol: Image(image: image).renderingMode(renderingMode)) } else { - view = Text("[🐶\(sticker.name)]").foregroundColor(.secondary) + // view = Text("[?\(sticker.name)]").foregroundColor(.secondary) + append("[?\(sticker.name)]") } - - append(view) } private func visit(tagged: Span.Tagged) { @@ -468,7 +452,7 @@ class ContentCombiner { private func visit(uid: Span.Tagged) { let combiner = ContentCombiner(parent: self, color: { _ in Color.accentColor }) - combiner.append(Text(Image(systemName: "person.fill"))) + combiner.append(symbol: Image(systemName: "person.fill")) combiner.visit(spans: uid.spans) var name: String? @@ -706,7 +690,7 @@ class ContentCombiner { guard let fn = mnga.attributes.first else { return } switch fn { case "version": - append(Text(getVersionWithBuild())) + append(getVersionWithBuild()) default: break } @@ -719,10 +703,10 @@ class ContentCombiner { } let combiner = ContentCombiner(parent: self) - let tagFont = Font.system(.footnote, design: .monospaced) - combiner.append(Text("[\(defaultTagged.tag)]").font(tagFont)) + // let tagFont = Font.system(.footnote, design: .monospaced) + combiner.append("[\(defaultTagged.tag)]") // TODO: font combiner.visit(spans: defaultTagged.spans) - combiner.append(Text("[/\(defaultTagged.tag)]").font(tagFont)) + combiner.append("[/\(defaultTagged.tag)]") // TODO: font append(combiner.build()) } } From 9717561616b6e3ee553dcbb394ebc95c1563025f Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Mon, 13 Oct 2025 11:12:04 +0800 Subject: [PATCH 5/5] bump Signed-off-by: Bugen Zhao --- app/.package.resolved | 2 +- app/Project.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/.package.resolved b/app/.package.resolved index f0ee89ea..2a29872b 100644 --- a/app/.package.resolved +++ b/app/.package.resolved @@ -77,7 +77,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/LiYanan2004/RichText.git", "state" : { - "revision" : "c569468fba2df5d76badb034d28858459cc37bd7" + "revision" : "300582da5c632864598cf9e8d86618e101658afa" } }, { diff --git a/app/Project.swift b/app/Project.swift index 40ae5b37..da0d57d7 100644 --- a/app/Project.swift +++ b/app/Project.swift @@ -21,7 +21,7 @@ let project = Project( .remote(url: "https://github.com/gh123man/LazyPager", requirement: .exact("1.1.13")), .remote(url: "https://github.com/tevelee/SwiftUI-Flow", requirement: .exact("3.1.0")), .remote(url: "https://github.com/SvenTiigi/WhatsNewKit.git", requirement: .exact("2.2.1")), - .remote(url: "https://github.com/LiYanan2004/RichText.git", requirement: .revision("c569468fba2df5d76badb034d28858459cc37bd7")), + .remote(url: "https://github.com/LiYanan2004/RichText.git", requirement: .revision("300582da5c632864598cf9e8d86618e101658afa")), ], targets: [ // iOS App Target