From b4ad2f1ad0f53ab3d70e6b15b807912b4e9f03e2 Mon Sep 17 00:00:00 2001 From: Akhil <11626756+buggerman@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:31:27 +0200 Subject: [PATCH] Make chat content selectable and add Copy context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift .textSelection(.enabled) to the LazyVStack containers in MessageList and ServerMessageList so every message kind — privmsg, notice, action, join/part/quit/nick/kick, topic, mode, and server numerics — is selectable, not just privmsg/notice content. Attach a .contextMenu to MessageRow with Copy Message, Copy Text, and Copy Nickname items. Copy Message emits a plain-text log-style line using the row's current timestamp format; Copy Text emits just the message body with mIRC control codes stripped; Copy Nickname emits the sender. SwiftUI's .textSelection only supports per-Text selection, not cross-row drag-selection. Proper mIRC-style multi-line copy requires an NSTextView-backed buffer and lands in a follow-up. --- Sources/Brygga/Views/ContentView.swift | 33 +++++++++++++++++++-- Sources/BryggaCore/IRC/IRCFormatting.swift | 8 +++++ Tests/IRCFormattingTests.swift | 34 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 Tests/IRCFormattingTests.swift diff --git a/Sources/Brygga/Views/ContentView.swift b/Sources/Brygga/Views/ContentView.swift index 33aa711..d7015d8 100644 --- a/Sources/Brygga/Views/ContentView.swift +++ b/Sources/Brygga/Views/ContentView.swift @@ -1,6 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026 Brygga contributors +import AppKit import BryggaCore import SwiftUI @@ -774,6 +775,7 @@ struct ServerMessageList: View { } } .padding(12) + .textSelection(.enabled) } .onChange(of: server.messages.count) { if let last = server.messages.last { @@ -893,6 +895,7 @@ struct MessageList: View { } } .padding(12) + .textSelection(.enabled) } .onChange(of: channel.messages.count) { if let last = channel.messages.last { @@ -1032,7 +1035,6 @@ struct MessageRow: View { .foregroundStyle(senderColor(message.sender)) Text(AttributedString.fromIRC(message.content)) .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) case .notice: Text("-\(message.sender)-") @@ -1041,7 +1043,6 @@ struct MessageRow: View { Text(AttributedString.fromIRC(message.content)) .font(.system(.body, design: .monospaced)) .foregroundStyle(.orange) - .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) case .action: Text("*") @@ -1059,6 +1060,34 @@ struct MessageRow: View { .frame(maxWidth: .infinity, alignment: .leading) } } + .contextMenu { + Button("Copy Message") { copy(plainLogLine) } + Button("Copy Text") { copy(IRCFormatting.stripControlCodes(message.content)) } + Button("Copy Nickname") { copy(message.sender) } + } + } + + /// Single-line plain-text rendering of the message suitable for the + /// pasteboard. Matches the on-screen layout (timestamp, sender decoration, + /// content) with all mIRC control codes stripped. + private var plainLogLine: String { + let body = IRCFormatting.stripControlCodes(message.content) + switch message.kind { + case .privmsg: + return "[\(timestampText)] <\(message.sender)> \(body)" + case .notice: + return "[\(timestampText)] -\(message.sender)- \(body)" + case .action: + return "[\(timestampText)] * \(message.sender) \(body)" + default: + return "[\(timestampText)] * \(message.sender) \(body)" + } + } + + private func copy(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) } } diff --git a/Sources/BryggaCore/IRC/IRCFormatting.swift b/Sources/BryggaCore/IRC/IRCFormatting.swift index e8fdbaf..0a55151 100644 --- a/Sources/BryggaCore/IRC/IRCFormatting.swift +++ b/Sources/BryggaCore/IRC/IRCFormatting.swift @@ -149,6 +149,14 @@ public enum IRCFormatting { return runs } + /// Returns `text` with all mIRC/IRCv3 formatting control bytes removed — + /// bold, italic, underline, strikethrough, reverse, reset, monospace, and + /// any `^K[,]` color sequences. Suitable for placing on the system + /// pasteboard when the user copies a message. + public static func stripControlCodes(_ text: String) -> String { + parse(text).map(\.text).joined() + } + // MARK: - mIRC 16-color palette (RGB in 0…1) public struct RGB: Sendable, Equatable { diff --git a/Tests/IRCFormattingTests.swift b/Tests/IRCFormattingTests.swift new file mode 100644 index 0000000..c46f605 --- /dev/null +++ b/Tests/IRCFormattingTests.swift @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026 Brygga contributors + +@testable import BryggaCore +import XCTest + +final class IRCFormattingTests: XCTestCase { + func testStripControlCodesRemovesBoldItalicUnderline() { + let input = "\u{02}bold\u{02} and \u{1D}italic\u{1D} and \u{1F}under\u{1F}" + XCTAssertEqual(IRCFormatting.stripControlCodes(input), "bold and italic and under") + } + + func testStripControlCodesRemovesColorSequences() { + let input = "\u{03}04red\u{03} back \u{03}04,02fg+bg\u{03} end" + XCTAssertEqual(IRCFormatting.stripControlCodes(input), "red back fg+bg end") + } + + func testStripControlCodesKeepsCommaWhenNotPartOfColor() { + // A bare ^K followed by non-digits resets colors; the literal comma + // survives because it's not a bg separator. + let input = "\u{03}3,meh" + XCTAssertEqual(IRCFormatting.stripControlCodes(input), ",meh") + } + + func testStripControlCodesRemovesResetAndReverse() { + let input = "a\u{0F}b\u{16}c\u{1E}d" + XCTAssertEqual(IRCFormatting.stripControlCodes(input), "abcd") + } + + func testStripControlCodesIsIdentityForPlainText() { + let input = "hello, world — no control codes here" + XCTAssertEqual(IRCFormatting.stripControlCodes(input), input) + } +}