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
36 changes: 34 additions & 2 deletions Compositor/Rendering/EditorCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ struct EditorCanvas: NSViewRepresentable {

final class CanvasView: NSView {
var inlineTextEditor: InlineTextEditor?
/// The text being typed, rendered as the layer will hold it, remade only when its style changes.
private var draftTextCache: (style: LayerTextStyle, image: CGImage)?
/// The text being typed as pixels, where the editor shows it (see InlineTextEditor).
private var draftText: (image: CGImage, transform: LayerTransform)? {
guard let draft = session.textDraft, let transform = inlineTextEditor?.shownTransform else { draftTextCache = nil; return nil }
if draftTextCache?.style != draft.style {
guard let image = try? EditorSession.textImage(draft.style) else { draftTextCache = nil; return nil }
draftTextCache = (draft.style, image)
}
return draftTextCache.map { ($0.image, transform) }
}
var textBoxAnchor: CGPoint?
var textBoxRect: CGRect?
private var lastFocusRequest = 0
Expand Down Expand Up @@ -474,6 +485,9 @@ final class CanvasView: NSView {
let transform: LayerTransform
}
let folderMasks: [FolderMask]
/// The text being typed, which the canvas draws as pixels.
let textStyle: LayerTextStyle?
let textTransform: LayerTransform?
}

@discardableResult
Expand Down Expand Up @@ -502,7 +516,7 @@ final class CanvasView: NSView {
folderMasks: (document?.layers ?? []).filter { $0.isGroup && $0.mask != nil }.map {
DisplayState.FolderMask(id: $0.id, maskID: $0.mask?.enabledImage.map { ObjectIdentifier($0) },
transform: session.displayedTransform(for: $0))
})
}, textStyle: session.textDraft?.style, textTransform: session.textDraft == nil ? nil : inlineTextEditor?.shownTransform)
var changed = false
if displayedState != state {
if let previous = displayedState, previous.documentID == state.documentID,
Expand Down Expand Up @@ -812,7 +826,15 @@ final class CanvasView: NSView {
}
let byID = Dictionary(uniqueKeysWithValues: document.layers.map { ($0.id, $0) })
func drawOwn(_ id: UUID, _ context: CGContext) {
guard let layer = byID[id], layer.id != session.textDraft?.layerID else { return }
guard let layer = byID[id] else { return }
// Text being edited draws as it will be committed, in its place among the layers.
if layer.id == session.textDraft?.layerID {
if let text = draftText {
LayerRenderer.draw(text.image, transform: text.transform, center: center(text.transform.center), scale: scale,
opacity: layer.effectiveOpacity(in: byID), blendMode: blendMode(of: layer), in: context)
}
return
}
// A folder the layer sits in dims it along with everything else inside (see LayerOpacity).
let opacity = layer.effectiveOpacity(in: byID)
let mode = session.displayedBlendMode(for: layer)
Expand Down Expand Up @@ -946,6 +968,15 @@ final class CanvasView: NSView {
drawOwn(id, context)
guard id == session.activeLayerID else { return }
drawShapeDraft(scale: scale, center: center, in: context)
drawNewText(context)
}
// New text goes where its layer will: just above the active layer, or on top when that isn't drawn (a
// folder, a hidden layer, or none).
var drewNewText = false
func drawNewText(_ context: CGContext) {
guard !drewNewText, session.textDraft?.layerID == nil, let text = draftText else { return }
drewNewText = true
LayerRenderer.draw(text.image, transform: text.transform, center: center(text.transform.center), scale: scale, in: context)
}
let live = LiveMaskRenderer(bounds: context.boundingBoxOfClipPath, source: { byID[$0]?.maskSourceID }, drawOwn: drawOwnWithDraft)
live.adjustment = { byID[$0]?.adjustment }
Expand Down Expand Up @@ -977,6 +1008,7 @@ final class CanvasView: NSView {
let origin = center(transform.center)
return { clip.apply(scale: scale, center: origin, in: $0) }
}, in: context) { live.drawComposite($0, in: context) }
drawNewText(context)
}

/// The shape being dragged out with the Shape tool, drawn in the color it will be made in.
Expand Down
11 changes: 8 additions & 3 deletions Compositor/Rendering/InlineTextEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import AppKit

/// A native text system on the canvas: selection, marked text/IME, clipboard and local undo
/// stay with NSTextView. Its logical bounds are layer pixels; the containing view supplies zoom.
/// Its glyphs are clear: the canvas draws the text as the layer's own pixels underneath, as Photoshop does, so
/// what is typed looks the same at any zoom as it will once it is committed.
final class CanvasTextView: NSTextView {
weak var editor: InlineTextEditor?
private let textUndo = UndoManager()
Expand Down Expand Up @@ -44,7 +46,7 @@ final class InlineTextEditor: NSView, NSTextViewDelegate {
private var synchronizing = false
private var logicalSize = CGSize(width: 360, height: 160)
private var handleSize: CGFloat = 6
private var shownTransform: LayerTransform?
private(set) var shownTransform: LayerTransform?
private struct Geometry: Equatable {
let transform: LayerTransform
let logicalSize: CGSize
Expand Down Expand Up @@ -76,6 +78,8 @@ final class InlineTextEditor: NSView, NSTextViewDelegate {
textView.textContainer?.heightTracksTextView = true
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
// The selection shows through to the text the canvas draws beneath it.
textView.selectedTextAttributes = [.backgroundColor: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.45)]
textView.setAccessibilityLabel("Canvas text")
// Both backed by layers from the start. Left to AppKit, the text surface's layer is first placed in the
// canvas's own layer tree and only moved inside this view a frame later; with a flipped layer, whose
Expand Down Expand Up @@ -148,14 +152,15 @@ final class InlineTextEditor: NSView, NSTextViewDelegate {
synchronizing = true
let selection = textView.selectedRange()
if textView.string != style.content { textView.string = style.content }
let attributes = EditorSession.textAttributes(style)
var attributes = EditorSession.textAttributes(style)
textView.insertionPointColor = (attributes[.foregroundColor] as? NSColor) ?? .white
attributes[.foregroundColor] = NSColor.clear
textView.typingAttributes = attributes
if !textView.hasMarkedText() {
textView.textStorage?.setAttributes(attributes, range: NSRange(location: 0, length: textView.string.utf16.count))
textView.setSelectedRange(NSRange(location: min(selection.location, textView.string.utf16.count),
length: min(selection.length, max(0, textView.string.utf16.count - selection.location))))
}
textView.insertionPointColor = (attributes[.foregroundColor] as? NSColor) ?? .white
shownStyle = style
synchronizing = false
needsDisplay = true
Expand Down
38 changes: 38 additions & 0 deletions CompositorTests/TypeToolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,42 @@ struct TypeToolTests {
#expect(!session.applyText(draft))
#expect(session.document?.layers.count == 1)
}

/// Zoomed in, text being typed shows as the pixels it will be committed as, so confirming it changes nothing on
/// screen — at a zoom that smooths pixels and at one that shows them hard-edged.
@Test(arguments: [1.5, 4] as [CGFloat])
func textLooksTheSameWhileEditingAndOnceCommitted(zoom: CGFloat) throws {
let session = makeSession()
let view = CanvasView(session: session)
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled], backing: .buffered, defer: false)
window.contentView = view
session.viewport.resize(to: view.bounds.size, backingScale: 1, documentSize: CGSize(width: 800, height: 600))
session.zoom(to: zoom)
func snapshot() throws -> [UInt8] {
// The canvas's own drawing, without the editor's box and handles over it.
view.synchronizeDisplay()
view.subviews.forEach { $0.isHidden = true }
defer { view.subviews.forEach { $0.isHidden = false } }
let rep = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: rep)
let data = try #require(rep.bitmapData)
return Array(UnsafeBufferPointer(start: data, count: rep.bytesPerRow * rep.pixelsHigh))
}
let blank = try snapshot()
session.beginText(at: CGPoint(x: 380, y: 300))
session.textDraft?.style.content = "Sharp"
session.textDraft?.style.fontSize = 24
view.synchronizeDisplay()
let editor = try #require(view.inlineTextEditor)
#expect(editor.textView.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .clear)

let editing = try snapshot()
#expect(editing != blank, "the text being typed wasn't drawn on the canvas")
#expect(session.finishText())
#expect(session.activeLayer?.liveText != nil)
let committed = try snapshot()
#expect(editing.count == committed.count)
let largest = zip(editing, committed).map { abs(Int($0) - Int($1)) }.max() ?? 0
#expect(largest <= 2, "the canvas changed by up to \(largest) when the text was committed")
}
}