Răsfoiți Sursa

Add font style and size pickers to the Add Text dialog.

Users can preview and apply typography choices before placing text on the canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 lună în urmă
părinte
comite
34bff8761b
1 a modificat fișierele cu 239 adăugiri și 19 ștergeri
  1. 239 19
      smart_printer/DrawPrintView.swift

+ 239 - 19
smart_printer/DrawPrintView.swift

@@ -28,11 +28,42 @@ struct CanvasStroke {
     var lineWidth: CGFloat
 }
 
+enum CanvasTextFontStyle: String, CaseIterable {
+    case regular = "Regular"
+    case bold = "Bold"
+    case italic = "Italic"
+    case boldItalic = "Bold Italic"
+    case serif = "Serif"
+    case monospace = "Monospace"
+
+    func font(size: CGFloat) -> NSFont {
+        switch self {
+        case .regular:
+            return AppTheme.regularFont(size: size)
+        case .bold:
+            return .systemFont(ofSize: size, weight: .bold)
+        case .italic:
+            let base = AppTheme.regularFont(size: size)
+            return NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) ?? base
+        case .boldItalic:
+            let bold = NSFont.systemFont(ofSize: size, weight: .bold)
+            return NSFontManager.shared.convert(bold, toHaveTrait: .italicFontMask) ?? bold
+        case .serif:
+            return NSFont(name: "Times New Roman", size: size)
+                ?? NSFont(name: "Times-Roman", size: size)
+                ?? .systemFont(ofSize: size)
+        case .monospace:
+            return .monospacedSystemFont(ofSize: size, weight: .regular)
+        }
+    }
+}
+
 struct CanvasTextData {
     var id: UUID
     var text: String
     var origin: NSPoint
     var fontSize: CGFloat
+    var fontStyle: CanvasTextFontStyle
     var color: NSColor
     var alignment: NSTextAlignment
 }
@@ -245,8 +276,8 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
 
         addTextPromptView.isHidden = true
         addTextPromptView.alphaValue = 0
-        addTextPromptView.onAdd = { [weak self] text in
-            self?.commitAddedText(text)
+        addTextPromptView.onAdd = { [weak self] result in
+            self?.commitAddedText(result)
         }
         addTextPromptView.onCancel = { [weak self] in
             self?.pendingTextPlacement = nil
@@ -270,7 +301,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         toolsPanel.setActiveTool(.pointer)
     }
 
-    private func commitAddedText(_ text: String) {
+    private func commitAddedText(_ result: AddTextResult) {
         let placement = pendingTextPlacement
         pendingTextPlacement = nil
         suppressCanvasTextPrompt = true
@@ -279,7 +310,12 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         layoutSubtreeIfNeeded()
         canvasContainer.layoutSubtreeIfNeeded()
         canvasView.layoutSubtreeIfNeeded()
-        canvasView.addTextItem(text: text, at: placement)
+        canvasView.addTextItem(
+            text: result.text,
+            fontSize: result.fontSize,
+            fontStyle: result.fontStyle,
+            at: placement
+        )
         toolsPanel.setActiveTool(.pointer)
 
         DispatchQueue.main.async { [weak self] in
@@ -549,7 +585,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         let paragraph = NSMutableParagraphStyle()
         paragraph.alignment = item.alignment
         let attributes: [NSAttributedString.Key: Any] = [
-            .font: AppTheme.regularFont(size: item.fontSize),
+            .font: item.fontStyle.font(size: item.fontSize),
             .foregroundColor: item.color,
             .paragraphStyle: paragraph,
         ]
@@ -774,10 +810,15 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         notifyUndoState()
     }
 
-    func addTextItem(text: String = "Text", at origin: NSPoint? = nil) {
+    func addTextItem(
+        text: String = "Text",
+        fontSize: CGFloat = 18,
+        fontStyle: CanvasTextFontStyle = .regular,
+        at origin: NSPoint? = nil
+    ) {
         guard bounds.width > 1, bounds.height > 1 else {
             DispatchQueue.main.async { [weak self] in
-                self?.addTextItem(text: text, at: origin)
+                self?.addTextItem(text: text, fontSize: fontSize, fontStyle: fontStyle, at: origin)
             }
             return
         }
@@ -788,7 +829,8 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
                 id: UUID(),
                 text: text,
                 origin: .zero,
-                fontSize: 18,
+                fontSize: fontSize,
+                fontStyle: fontStyle,
                 color: .black,
                 alignment: .left
             )
@@ -812,7 +854,8 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             id: UUID(),
             text: text,
             origin: clampedOrigin,
-            fontSize: 18,
+            fontSize: fontSize,
+            fontStyle: fontStyle,
             color: .black,
             alignment: .left
         )
@@ -863,7 +906,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         pushSnapshot()
         textItems[index].fontSize = size
         if let field = textField(for: id) {
-            field.font = AppTheme.regularFont(size: size)
+            field.font = textItems[index].fontStyle.font(size: size)
             field.frame = textBounds(for: textItems[index])
         }
         needsDisplay = true
@@ -955,7 +998,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         field.isBordered = false
         field.isBezeled = false
         field.drawsBackground = false
-        field.font = AppTheme.regularFont(size: item.fontSize)
+        field.font = item.fontStyle.font(size: item.fontSize)
         field.textColor = item.color
         field.alignment = item.alignment
         field.frame = textBounds(for: item)
@@ -996,7 +1039,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
 
     private func textBounds(for item: CanvasTextData) -> NSRect {
         let attributes: [NSAttributedString.Key: Any] = [
-            .font: AppTheme.regularFont(size: item.fontSize),
+            .font: item.fontStyle.font(size: item.fontSize),
         ]
         let textSize = (item.text as NSString).size(withAttributes: attributes)
         let width = max(textSize.width + 8, 80)
@@ -1124,7 +1167,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         textItems[index].origin = clampedOrigin(origin, size: bounds.size)
 
         if let field = textField(for: textItems[index].id) {
-            field.font = AppTheme.regularFont(size: newFontSize)
+            field.font = textItems[index].fontStyle.font(size: newFontSize)
             field.frame = textBounds(for: textItems[index])
         }
     }
@@ -1436,17 +1479,143 @@ private final class PlaceholderTextView: NSTextView {
     }
 }
 
+struct AddTextResult {
+    let text: String
+    let fontSize: CGFloat
+    let fontStyle: CanvasTextFontStyle
+}
+
+private final class AddTextOptionPicker: NSControl, AppearanceRefreshable {
+    var onChange: ((String) -> Void)?
+
+    private let titleLabel = NSTextField()
+    private let chevronView = NSImageView()
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
+    private let optionMenu = NSMenu()
+    private var options: [String] = []
+    private var selectedOption = ""
+
+    init(options: [String], selection: String) {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.cornerRadius = 16
+        layer?.borderWidth = 1.5
+
+        self.options = options
+        selectedOption = selection
+
+        titleLabel.stringValue = selection
+        titleLabel.font = AppTheme.mediumFont(size: 16)
+        titleLabel.isBordered = false
+        titleLabel.isEditable = false
+        titleLabel.drawsBackground = false
+        titleLabel.lineBreakMode = .byTruncatingTail
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        if let image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: "Options") {
+            let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
+            chevronView.image = image.withSymbolConfiguration(config)
+        }
+        chevronView.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(titleLabel)
+        addSubview(chevronView)
+
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 52),
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -10),
+            chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
+            chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
+            chevronView.widthAnchor.constraint(equalToConstant: 16),
+            chevronView.heightAnchor.constraint(equalToConstant: 16),
+        ])
+
+        rebuildMenu()
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.setHovered(hovering)
+        }
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func setSelection(_ value: String) {
+        guard options.contains(value) else { return }
+        selectedOption = value
+        titleLabel.stringValue = value
+    }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = isHovered
+            ? AppTheme.elevatedBackground.cgColor
+            : AppTheme.cardBackground.cgColor
+        layer?.borderColor = AppTheme.paywallBorder.cgColor
+        titleLabel.textColor = AppTheme.textPrimary
+        chevronView.contentTintColor = AppTheme.textPrimary
+    }
+
+    private func setHovered(_ hovering: Bool) {
+        isHovered = hovering
+        animateHover {
+            layer?.backgroundColor = hovering
+                ? AppTheme.elevatedBackground.cgColor
+                : AppTheme.cardBackground.cgColor
+        }
+    }
+
+    private func rebuildMenu() {
+        optionMenu.removeAllItems()
+        for option in options {
+            let item = NSMenuItem(title: option, action: #selector(menuItemSelected(_:)), keyEquivalent: "")
+            item.target = self
+            item.state = option == selectedOption ? .on : .off
+            optionMenu.addItem(item)
+        }
+    }
+
+    @objc private func menuItemSelected(_ sender: NSMenuItem) {
+        let value = sender.title
+        guard options.contains(value) else { return }
+        selectedOption = value
+        titleLabel.stringValue = value
+        rebuildMenu()
+        onChange?(value)
+    }
+
+    override func mouseDown(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        rebuildMenu()
+        optionMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.height + 4), in: self)
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
 private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextViewDelegate {
-    private static let inputFontSize: CGFloat = 20
+    private static let defaultFontSize: CGFloat = 18
+    private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
     private static let inputTextInset = NSSize(width: 16, height: 14)
     private static let inputLineFragmentPadding: CGFloat = 5
 
-    var onAdd: ((String) -> Void)?
+    var onAdd: ((AddTextResult) -> Void)?
     var onCancel: (() -> Void)?
 
+    private var selectedFontSize = defaultFontSize
+    private var selectedFontStyle: CanvasTextFontStyle = .regular
+
     private let dimView = NSView()
     private let cardView = NSView()
     private let titleLabel = NSTextField(labelWithString: "Add Text")
+    private let optionsStack = NSStackView()
+    private let fontStylePicker: AddTextOptionPicker
+    private let fontSizePicker: AddTextOptionPicker
     private let inputScrollView = NSScrollView()
     private let inputTextView = PlaceholderTextView()
     private let buttonStack = NSStackView()
@@ -1454,6 +1623,14 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
     private let cancelButton = DrawPrintSecondaryButton(title: "Cancel", symbolName: "xmark", compact: true, firesOnMouseDown: true)
 
     override init(frame frameRect: NSRect) {
+        fontStylePicker = AddTextOptionPicker(
+            options: CanvasTextFontStyle.allCases.map(\.rawValue),
+            selection: CanvasTextFontStyle.regular.rawValue
+        )
+        fontSizePicker = AddTextOptionPicker(
+            options: Self.fontSizeOptions.map { "\(Int($0))" },
+            selection: "\(Int(Self.defaultFontSize))"
+        )
         super.init(frame: frameRect)
         translatesAutoresizingMaskIntoConstraints = false
         setup()
@@ -1473,10 +1650,17 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
         inputTextView.placeholderColor = AppTheme.textSecondary
         addButton.refreshAppearance()
         cancelButton.refreshAppearance()
+        fontStylePicker.refreshAppearance()
+        fontSizePicker.refreshAppearance()
     }
 
     func prepareForPresentation() {
         inputTextView.string = ""
+        selectedFontSize = Self.defaultFontSize
+        selectedFontStyle = .regular
+        fontStylePicker.setSelection(CanvasTextFontStyle.regular.rawValue)
+        fontSizePicker.setSelection("\(Int(Self.defaultFontSize))")
+        applyInputFont()
         inputTextView.needsDisplay = true
     }
 
@@ -1500,6 +1684,18 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
         titleLabel.alignment = .center
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
+        optionsStack.orientation = .horizontal
+        optionsStack.spacing = 12
+        optionsStack.distribution = .fillEqually
+        optionsStack.translatesAutoresizingMaskIntoConstraints = false
+
+        fontStylePicker.onChange = { [weak self] value in
+            self?.fontStyleChanged(value)
+        }
+        fontSizePicker.onChange = { [weak self] value in
+            self?.fontSizeChanged(value)
+        }
+
         inputScrollView.translatesAutoresizingMaskIntoConstraints = false
         inputScrollView.drawsBackground = false
         inputScrollView.hasVerticalScroller = true
@@ -1507,7 +1703,7 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
         inputScrollView.borderType = .noBorder
 
         inputTextView.isRichText = false
-        inputTextView.font = AppTheme.regularFont(size: Self.inputFontSize)
+        inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
         inputTextView.textContainerInset = Self.inputTextInset
         inputTextView.textContainer?.lineFragmentPadding = Self.inputLineFragmentPadding
         inputTextView.placeholderString = "Enter any text here..."
@@ -1532,6 +1728,9 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
         addSubview(dimView)
         addSubview(cardView)
         cardView.addSubview(titleLabel)
+        cardView.addSubview(optionsStack)
+        optionsStack.addArrangedSubview(fontStylePicker)
+        optionsStack.addArrangedSubview(fontSizePicker)
         cardView.addSubview(inputScrollView)
         cardView.addSubview(buttonStack)
         buttonStack.addArrangedSubview(addButton)
@@ -1549,15 +1748,19 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
             cardView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.62),
             cardView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 40),
             cardView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -40),
-            cardView.heightAnchor.constraint(equalToConstant: 280),
+            cardView.heightAnchor.constraint(equalToConstant: 320),
 
             titleLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 18),
             titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
             titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
 
+            optionsStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
+            optionsStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
+            optionsStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
+
             inputScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
             inputScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
-            inputScrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 14),
+            inputScrollView.topAnchor.constraint(equalTo: optionsStack.bottomAnchor, constant: 12),
             inputScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16),
 
             buttonStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
@@ -1571,7 +1774,24 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
         let raw = inputTextView.textStorage?.string ?? inputTextView.string
         let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !text.isEmpty else { return }
-        onAdd?(text)
+        onAdd?(AddTextResult(text: text, fontSize: selectedFontSize, fontStyle: selectedFontStyle))
+    }
+
+    private func fontStyleChanged(_ title: String) {
+        guard let style = CanvasTextFontStyle(rawValue: title) else { return }
+        selectedFontStyle = style
+        applyInputFont()
+    }
+
+    private func fontSizeChanged(_ title: String) {
+        guard let size = Double(title) else { return }
+        selectedFontSize = CGFloat(size)
+        applyInputFont()
+    }
+
+    private func applyInputFont() {
+        inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
+        inputTextView.needsDisplay = true
     }
 
     @objc private func handleCancel() {