Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
--- a/Sources/MessagesLabHome/Vendor/appkit-native/Compose.swift
+++ b/Sources/MessagesLabHome/Vendor/appkit-native/Compose.swift
@@ -7,7 +7,19 @@
@@ -7,7 +7,25 @@
final class FieldTextView: NSTextView {
var onSend: () -> Void = {}
var onEscape: () -> Void = {}
Expand All @@ -18,18 +18,24 @@
+ /// cmux: a drop on the field that the host's intake takes (the paste's
+ /// type rule). A plain-text NSTextView would type a dropped file's path.
+ var onDropPasteboard: (NSPasteboard) -> Bool = { _ in false }
+ /// cmux: whether the host's intake would take a drag's pasteboard (a drag also counts
+ /// file promises, which a paste cannot receive); nil uses `acceptsPastePasteboard`.
+ var acceptsDropPasteboard: ((NSPasteboard) -> Bool)?
+ /// cmux: the field became (true) or stopped being (false) the target of a drag the host's
+ /// intake takes: the pane's drop highlight follows it.
+ var onDropTargetChange: (Bool) -> Void = { _ in }
/// Marked (IME) text changed. UITextView reports marked text through
/// `textViewDidChange`, so the draft (and the field height) follows the
/// composition; NSTextView does not post a text change for it.
@@ -17,6 +29,7 @@
@@ -17,6 +35,7 @@
/// The release of that press (the mouse-up that ended the text view's tracking).
var onRelease: (NSEvent) -> Void = { _ in }
override func mouseDown(with event: NSEvent) {
+ pickerAnchor = nil // cmux
onPress(event)
super.mouseDown(with: event)
if let up = NSApp.currentEvent, up.type == .leftMouseUp { onRelease(up) }
@@ -48,12 +61,14 @@
@@ -48,12 +67,14 @@
override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties(); applyScale() }

override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
Expand All @@ -44,7 +50,7 @@
defer { if Self.caretLog { Self.log("key done") } }
// Marked text (IME composition) owns Return and Esc.
if hasMarkedText() { super.keyDown(with: event); return }
@@ -101,6 +116,12 @@
@@ -101,6 +122,12 @@
}
var caretSuspended = false { didSet { updateCaret(reset: false) } }

Expand All @@ -57,7 +63,7 @@
func installCaret() {
guard Self.ownCaret else { return }
insertionPointColor = .clear
@@ -141,7 +162,8 @@
@@ -141,7 +168,8 @@
if let r = caretRect(at: sel.location, upstream: textLayoutManager?.textSelections.first?.affinity == .upstream) {
local = CGRect(x: r.minX + Self.caretDX, y: r.minY, width: Self.caretWidth, height: 17)
} else {
Expand All @@ -67,7 +73,7 @@
let r = convert(window.convertFromScreen(screen), from: nil)
local = CGRect(x: r.minX + Self.caretDX, y: r.midY + Self.caretDY, width: Self.caretWidth, height: 17)
}
@@ -183,11 +205,13 @@
@@ -183,11 +211,13 @@
/// not depend on the blink's phase at the grab (Messages' still references show it solid).
static let solidCaret = ProcessInfo.processInfo.arguments.contains("--still-grab")

Expand All @@ -83,15 +89,15 @@
let a = CAKeyframeAnimation(keyPath: "opacity")
a.calculationMode = .discrete
// One period from the first fade-out: out 1 -> 0 in four steps, off, in 0 -> 1.
@@ -208,6 +232,7 @@
@@ -208,6 +238,7 @@
return ok
}
override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) {
+ pickerAnchor = nil // cmux
caretByEdit = editing
super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting)
// A caret move (an arrow key) shows the caret by this rule whatever AppKit scrolls; an
@@ -231,6 +256,9 @@
@@ -231,6 +262,9 @@
if DeferredSpellChecker.useAppKit { super.toggleContinuousSpellChecking(sender) } else { spell.enabled.toggle() }
}
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
Expand All @@ -101,15 +107,15 @@
if !DeferredSpellChecker.useAppKit, item.action == #selector(toggleContinuousSpellChecking(_:)) {
(item as? NSMenuItem)?.state = spell.enabled ? .on : .off
return true
@@ -243,6 +271,7 @@
@@ -243,6 +277,7 @@
return ok
}
override func resignFirstResponder() -> Bool {
+ pickerAnchor = nil // cmux
let ok = super.resignFirstResponder()
DispatchQueue.main.async { [weak self] in self?.updateCaret(reset: false) }
return ok
@@ -288,6 +317,18 @@
@@ -288,6 +323,18 @@
private var storageEdits = 0
private var storageObserver: NSObjectProtocol?
private var observedStorage: ObjectIdentifier?
Expand All @@ -128,7 +134,7 @@
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
applyScale()
@@ -377,13 +418,36 @@
@@ -377,13 +424,36 @@
tlm.ensureLayout(for: tlm.documentRange)
laidOut = state
}
Expand Down Expand Up @@ -170,7 +176,7 @@
}
/// Self-test: the caret's rects at INDICES from the layout as `caretRect` keeps it, then after the
/// whole text is laid out again from scratch (equal when no layout input went stale).
@@ -419,14 +483,88 @@
@@ -419,14 +489,95 @@
return super.scrollToVisible(rect)
}

Expand Down Expand Up @@ -200,7 +206,7 @@
+ if onPastePasteboard(pb) { return } // cmux
+ if let onPasteImage, pb.string(forType: .string) == nil, let img = NSImage(pasteboard: pb) { onPasteImage(img); return } // cmux
super.paste(sender)
}
+ }
+
+ /// cmux: Paste and Match Style (Edit menu, Cmd+Shift+V): the clipboard's
+ /// plain text only, at the caret (replacing the selection), as its own undo
Expand Down Expand Up @@ -229,23 +235,29 @@
+ private var dragAnswer: (sequence: Int, accepts: Bool)?
+ private func takesDrag(_ sender: any NSDraggingInfo) -> Bool {
+ if let a = dragAnswer, a.sequence == sender.draggingSequenceNumber { return a.accepts }
+ let accepts = acceptsPastePasteboard(sender.draggingPasteboard)
+ let accepts = (acceptsDropPasteboard ?? acceptsPastePasteboard)(sender.draggingPasteboard) // cmux
+ dragAnswer = (sender.draggingSequenceNumber, accepts)
+ return accepts
+ }
+ override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
+ dragAnswer = nil
+ return takesDrag(sender) ? .copy : super.draggingEntered(sender)
+ let takes = takesDrag(sender) // cmux
+ onDropTargetChange(takes) // cmux
+ return takes ? .copy : super.draggingEntered(sender) // cmux
+ }
+ override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
+ takesDrag(sender) ? .copy : super.draggingUpdated(sender)
+ }
+ let takes = takesDrag(sender) // cmux
+ onDropTargetChange(takes) // cmux: the host ignores a repeat
+ return takes ? .copy : super.draggingUpdated(sender) // cmux
}
+ override func draggingExited(_ sender: (any NSDraggingInfo)?) {
+ dragAnswer = nil
+ onDropTargetChange(false) // cmux
+ super.draggingExited(sender)
+ }
+ override func draggingEnded(_ sender: any NSDraggingInfo) {
+ dragAnswer = nil
+ onDropTargetChange(false) // cmux
+ super.draggingEnded(sender)
+ }
+ override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
Expand All @@ -254,13 +266,14 @@
+ override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
+ let takes = takesDrag(sender)
+ dragAnswer = nil
+ onDropTargetChange(false) // cmux
+ if takes { return onDropPasteboard(sender.draggingPasteboard) }
+ return super.performDragOperation(sender)
+ }
}

/// The caret's view: drawn only, never hit (clicks at the caret reach the text view).
@@ -632,10 +770,40 @@
@@ -632,10 +783,40 @@
var onAttachmentsChanged: (() -> Void)?
/// The send's field opacity pulse (begin), for the live glass view.
var onSendPulse: ((CFTimeInterval) -> Void)?
Expand Down Expand Up @@ -302,7 +315,7 @@
static func fieldWidth(_ windowWidth: CGFloat) -> CGFloat { 526 + windowWidth - Fixture.windowWidth }
static let font = Fixture.bodyFont
/// `writingTools`' raw value for reports: -1 on macOS 14 (no Writing Tools).
@@ -658,7 +826,7 @@
@@ -658,7 +839,7 @@
/// its text scrolls past it).
func lines(_ text: String, windowWidth: CGFloat) -> Int {
// One-entry memo: every compose update asks again for the same draft (a send asks for "").
Expand All @@ -311,7 +324,7 @@
// The empty field (every send and every commit after it) has its own entry: the one-entry
// memo held the last typed draft, so each send typeset " " again.
// Its one line (a space) does not depend on the width: a live resize's every width
@@ -678,6 +846,11 @@
@@ -678,6 +859,11 @@
/// The field's height for the draft in a window WIDTH pt wide (the attachment row on top).
private func height(for text: String, width: CGFloat) -> CGFloat {
if emptyAfterSend && text.isEmpty && chips.isEmpty { return ComposeMetrics.emptyAfterSend }
Expand All @@ -323,15 +336,15 @@
return min(ComposeView.height(lines: lines(text, windowWidth: width), chips: false), ComposeView.maxHeight) + strip.height
}
private var linesMemo: (String, CGFloat, Int)?
@@ -749,6 +922,7 @@
@@ -749,6 +935,7 @@
tv.textColor = Fixture.incomingText
tv.insertionPointColor = Fixture.caret
tv.installCaret()
+ inlineHover.install(on: tv) // cmux: an inline attachment's remove button on hover
tv.textContainer?.lineFragmentPadding = 0
// AppKit puts `textContainerInset.height` above and below the text when it sizes the
// document; the container origin puts the text at the top inset.
@@ -767,7 +941,7 @@
@@ -767,7 +954,7 @@
// tracking (which lagged through a live resize).
tv.textContainer?.widthTracksTextView = false
tv.textContainer?.heightTracksTextView = false
Expand All @@ -340,15 +353,15 @@
tv.layerContentsPlacement = .topLeft
tv.layer?.masksToBounds = true
tv.layer?.contentsGravity = .topLeft
@@ -790,6 +964,7 @@
@@ -790,6 +977,7 @@
tv.isAutomaticDashSubstitutionEnabled = NSSpellChecker.isAutomaticDashSubstitutionEnabled
if #available(macOS 15, *) { tv.writingToolsBehavior = ComposeView.writingTools }
tv.setAccessibilityLabel(Strings.placeholder)
+ tv.setAccessibilityIdentifier("cmux.home.composer")

caret.backgroundColor = Fixture.caret.cgColor
caret.isHidden = true
@@ -835,6 +1010,8 @@
@@ -835,6 +1023,8 @@
if !nativeChrome { renderGlass(); buttons.setNeedsDisplay() }
renderPlaceholder()
renderChips()
Expand All @@ -357,7 +370,7 @@
}

/// The "+" button (opens the file picker).
@@ -849,7 +1026,8 @@
@@ -849,7 +1039,8 @@
private func textFrame(height h: CGFloat) -> CGRect {
let f = rect(height: h)
let chipH: CGFloat = strip.height
Expand All @@ -367,7 +380,7 @@
}

/// What the last `place` laid out (field height, empty text, chip row height, size): a commit
@@ -867,10 +1045,11 @@
@@ -867,10 +1058,11 @@
placeholderLayer.frame = CGRect(x: f.minX, y: f.minY + chipH, width: 300, height: 30)
waveLayer.frame = CGRect(x: f.maxX - 30, y: ComposeView.waveY(f), width: 24, height: 30)
chipsLayer.frame = CGRect(x: f.minX, y: f.minY, width: f.width, height: max(1, strip.height))
Expand All @@ -380,7 +393,7 @@
}

/// The audio glyph's layer top for the field F: centred in the field, as Messages' (its glyph
@@ -940,11 +1119,11 @@
@@ -940,11 +1132,11 @@
fmt.opaque = false
let ph = placeholder
placeholderLayer.contents = UIGraphicsImageRenderer(size: CGSize(width: 300, height: 30), format: fmt).image { ctx in
Expand All @@ -394,14 +407,14 @@
// Messages' glyph is ChatKit's AudioMessageEntryViewButton waveform at 20 pt
// (fitted to the lossless stills): 5 capsules 1.818 pt wide, 3.636 pt apart,
// 3.613 / 7.25 / 14.522 pt tall; centre 557.5 pt from the left of a 628 pt window.
@@ -958,9 +1137,56 @@
@@ -958,9 +1150,56 @@
}

private func renderChips() {
- strip.update(chips, width: ComposeView.fieldWidth(bounds.width), scale: renderScale)
+ strip.update(stripChips, width: ComposeView.fieldWidth(bounds.width), scale: renderScale)
}
+ }
+
+ // cmux: attachments sit in the field's text at the caret (Draft+InlineMarks.swift), not in a
+ // strip above it; the strip stays empty.
+ private var stripChips: [Attachment] { [] }
Expand Down Expand Up @@ -432,8 +445,8 @@
+ let marks = Draft.markOffsets(d.text)
+ guard marks.count == d.attachments.count, let at = marks[checked: k] else { return nil }
+ return at + 1
+ }
+
}
+ /// The caret after the field's text was built again: just after a mark that came in (an
+ /// attachment entered at the caret), else where it was (clamped).
+ static func caretAfterRebuild(old: NSString, new: NSString, selection: NSRange, added: Int? = nil) -> NSRange {
Expand All @@ -452,7 +465,7 @@
/// The attachment whose remove control is at p (window coordinates).
func chip(at p: CGPoint) -> ID? {
let f = fieldRect
@@ -984,12 +1210,19 @@
@@ -984,12 +1223,19 @@
let d = state.ui.draft
let hadDraft = !text.isEmpty || !chips.isEmpty
let tv = textView.view
Expand All @@ -475,7 +488,7 @@
if !DeferredSpellChecker.useAppKit { tv.spell.textDidChange() }
}
}
@@ -1007,8 +1240,7 @@
@@ -1007,8 +1253,7 @@
CATransaction.begin(); CATransaction.setDisableActions(true)
// With an attachment Messages shows neither the placeholder nor the audio glyph
// (compose-attach-image stills).
Expand All @@ -485,7 +498,7 @@
CATransaction.commit()
let h = height(for: d.text, width: bounds.width)
let old = fieldHeight
@@ -1045,6 +1277,7 @@
@@ -1045,6 +1290,7 @@
Animate.sampledUntilMinimum(veil, "opacity", Springs.fieldOpacity, base: 1, begin: begin)
onSendPulse?(begin)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ protocol ChatIntents: AnyObject {
/// A paste in the field or a drop: true when the host's attachment
/// intake took the pasteboard (files and pictures, Home's type rule).
func takeAttachments(from pasteboard: NSPasteboard) -> Bool
/// While dragging: whether a drop would be taken (types only).
/// Whether a paste would be taken (types only).
func acceptsAttachments(from pasteboard: NSPasteboard) -> Bool
/// A drop on the field: true when the host's intake took the drag's
/// pasteboard (file URLs, file promises, pictures).
func takeDroppedAttachments(from pasteboard: NSPasteboard) -> Bool
/// While dragging over the field: whether a drop would be taken (types only).
func acceptsDroppedAttachments(from pasteboard: NSPasteboard) -> Bool
/// The field became or stopped being the target of a drag the host takes.
func attachmentDropTargeted(_ targeted: Bool)
/// A click on the status line (the Chief's "working on" line): `rect` is its text in `view`'s
/// coordinates, where the turn's details open.
func statusLineClicked(_ rect: CGRect, in view: NSView)
Expand Down Expand Up @@ -87,4 +94,8 @@ public protocol ChatWakeScheduler: AnyObject {
extension ChatIntents {
/// No local copy (hosts and fakes without attachment bytes).
func attachmentFile(_ message: ID, _ attachment: ID) async -> URL? { nil }
/// Hosts without a drop intake of their own take a drop as a paste.
func takeDroppedAttachments(from pasteboard: NSPasteboard) -> Bool { takeAttachments(from: pasteboard) }
func acceptsDroppedAttachments(from pasteboard: NSPasteboard) -> Bool { acceptsAttachments(from: pasteboard) }
func attachmentDropTargeted(_ targeted: Bool) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,14 @@ final class HomeProjection: @preconcurrency ChatIntents {
var onPickAttachments: () -> Void = {}
/// A paste or a drop; true when taken.
var onAttachmentPasteboard: (NSPasteboard) -> Bool = { _ in false }
/// While dragging, types only.
/// Whether a paste would be taken, types only.
var acceptsAttachmentDrag: (NSPasteboard) -> Bool = { _ in false }
/// A drop on the field; true when taken. Nil takes it as a paste.
var onAttachmentDrop: ((NSPasteboard) -> Bool)?
/// While dragging over the field, types only. Nil answers as for a paste.
var acceptsAttachmentDrop: ((NSPasteboard) -> Bool)?
/// The field became or stopped being the target of a drag the host takes.
var onAttachmentDropTarget: (Bool) -> Void = { _ in }
/// The store refused a send's attachment before logging it; the draft is back.
var onAttachmentRefusal: (HomeAttachmentError) -> Void = { _ in }
/// The field's text changed.
Expand Down Expand Up @@ -594,6 +600,9 @@ final class HomeProjection: @preconcurrency ChatIntents {
func pickAttachments() { onPickAttachments() }
func takeAttachments(from pasteboard: NSPasteboard) -> Bool { onAttachmentPasteboard(pasteboard) }
func acceptsAttachments(from pasteboard: NSPasteboard) -> Bool { acceptsAttachmentDrag(pasteboard) }
func takeDroppedAttachments(from pasteboard: NSPasteboard) -> Bool { (onAttachmentDrop ?? onAttachmentPasteboard)(pasteboard) }
func acceptsDroppedAttachments(from pasteboard: NSPasteboard) -> Bool { (acceptsAttachmentDrop ?? acceptsAttachmentDrag)(pasteboard) }
func attachmentDropTargeted(_ targeted: Bool) { onAttachmentDropTarget(targeted) }
func statusLineClicked(_ rect: CGRect, in view: NSView) {
let popover = turnPopover ?? {
let made = HomeTurnStatusPopover { [weak self] in self?.liveLine.details() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,17 +390,32 @@ public final class MessagesLabHomeView: NSView {
get { projection.onPickAttachments }
set { projection.onPickAttachments = newValue }
}
/// A paste in the field or a drop on the pane: true when the host's
/// intake took the pasteboard.
/// A paste in the field: true when the host's intake took the pasteboard.
public var onAttachmentPasteboard: (NSPasteboard) -> Bool {
get { projection.onAttachmentPasteboard }
set { projection.onAttachmentPasteboard = newValue }
}
/// While dragging (types only): whether a drop would be taken.
/// Whether a paste would be taken (types only).
public var acceptsAttachmentDrag: (NSPasteboard) -> Bool {
get { projection.acceptsAttachmentDrag }
set { projection.acceptsAttachmentDrag = newValue }
}
/// A drop on the field: true when the host's intake took the drag's pasteboard
/// (nil: as a paste). A drop on the rest of the pane reaches the host's own view.
public var onAttachmentDrop: ((NSPasteboard) -> Bool)? {
get { projection.onAttachmentDrop }
set { projection.onAttachmentDrop = newValue }
}
/// While dragging over the field (types only): whether a drop would be taken (nil: as a paste).
public var acceptsAttachmentDrop: ((NSPasteboard) -> Bool)? {
get { projection.acceptsAttachmentDrop }
set { projection.acceptsAttachmentDrop = newValue }
}
/// The field became (true) or stopped being (false) the target of a drag the host takes.
public var onAttachmentDropTarget: (Bool) -> Void {
get { projection.onAttachmentDropTarget }
set { projection.onAttachmentDropTarget = newValue }
}
/// The store refused a send's attachment before logging it; the text
/// and attachments are back in the field.
public var onAttachmentRefusal: (HomeAttachmentError) -> Void {
Expand Down
Loading