From 63776a1f452f14d8477fe99d8d209b766f0d0f87 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 19 Oct 2024 22:31:53 -0700 Subject: [PATCH 1/9] ItemBox implementation --- .../TextView/TextView+ItemBox.swift | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 Sources/CodeEditTextView/TextView/TextView+ItemBox.swift diff --git a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift new file mode 100644 index 00000000..ee22c2d9 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift @@ -0,0 +1,291 @@ +// +// TextView+ItemBox.swift +// CodeEditTextView +// +// Created by Abe Malla on 6/18/24. +// + +import AppKit +import SwiftUI + +public protocol ItemBoxEntry { + var view: NSView { get } +} + +public final class ItemBoxWindowController: NSWindowController { + + public static let DEFAULT_SIZE = NSSize(width: 300, height: 212) + + public var items: [any ItemBoxEntry] = [] { + didSet { + updateItems() + } + } + + private let tableView = NSTableView() + private let scrollView = NSScrollView() + private var localEventMonitor: Any? + + public var isVisible: Bool { + return window?.isVisible ?? false + } + + public init() { + let window = NSWindow( + contentRect: NSRect(origin: CGPoint.zero, size: ItemBoxWindowController.DEFAULT_SIZE), + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + // Style window + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isExcludedFromWindowsMenu = true + window.isReleasedWhenClosed = false + window.level = .popUpMenu + window.hasShadow = true + window.isOpaque = false + window.tabbingMode = .disallowed + window.hidesOnDeactivate = true + window.backgroundColor = .clear + window.minSize = ItemBoxWindowController.DEFAULT_SIZE + + // Style the content with custom borders and colors + window.contentView?.wantsLayer = true + window.contentView?.layer?.backgroundColor = CGColor( + srgbRed: 31.0 / 255.0, green: 31.0 / 255.0, blue: 36.0 / 255.0, alpha: 1.0 + ) + window.contentView?.layer?.cornerRadius = 8 + window.contentView?.layer?.borderWidth = 1 + window.contentView?.layer?.borderColor = NSColor.gray.withAlphaComponent(0.4).cgColor + let innerShadow = NSShadow() + innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) + innerShadow.shadowOffset = NSSize(width: 0, height: -1) + innerShadow.shadowBlurRadius = 2 + window.contentView?.shadow = innerShadow + + super.init(window: window) + + setupTableView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Opens the window of items + public func show() { + super.showWindow(nil) + setupEventMonitor() + } + + public func showWindow(attachedTo parentWindow: NSWindow) { + guard let window = self.window else { return } + parentWindow.addChildWindow(window, ordered: .above) + window.orderFront(nil) + + // Close on window switch + NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: parentWindow, + queue: .current + ) { [weak self] _ in + self?.close() + } + + self.show() + } + + public override func close() { + guard isVisible else { return } + removeEventMonitor() + super.close() + } + + private func setupTableView() { + tableView.delegate = self + tableView.dataSource = self + tableView.headerView = nil + tableView.backgroundColor = .clear + tableView.intercellSpacing = .zero + tableView.selectionHighlightStyle = .none + tableView.backgroundColor = .clear + tableView.enclosingScrollView?.drawsBackground = false + tableView.rowHeight = 24 + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) + column.width = ItemBoxWindowController.DEFAULT_SIZE.width + tableView.addTableColumn(column) + + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + scrollView.verticalScroller?.controlSize = .large + scrollView.autohidesScrollers = true + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = NSEdgeInsetsZero + window?.contentView?.addSubview(scrollView) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: window!.contentView!.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: window!.contentView!.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: window!.contentView!.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: window!.contentView!.bottomAnchor) + ]) + } + + private func updateItems() { + tableView.reloadData() + } + + public func tableViewSelectionDidChange(_ notification: Notification) { + tableView.enumerateAvailableRowViews { (rowView, row) in + if let cellView = rowView.view(atColumn: 0) as? CustomTableCellView { + cellView.backgroundStyle = tableView.selectedRow == row ? .emphasized : .normal + } + } + } + + private func setupEventMonitor() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .leftMouseDown, .rightMouseDown] + ) { [weak self] event in + guard let self = self else { return event } + + switch event.type { + case .keyDown: + switch event.keyCode { + case 53: // Escape key + self.close() + case 125: // Down arrow + self.selectNextItemInTable() + return nil + case 126: // Up arrow + self.selectPreviousItemInTable() + return nil + case 36: // Return key + return nil + default: + break + } + case .leftMouseDown, .rightMouseDown: + // If we click outside the window, close the window + if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { + self.close() + } + default: + break + } + + return event + } + } + + private func removeEventMonitor() { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + } + + private func selectNextItemInTable() { + let nextIndex = min(tableView.selectedRow + 1, items.count - 1) + tableView.selectRowIndexes(IndexSet(integer: nextIndex), byExtendingSelection: false) + tableView.scrollRowToVisible(nextIndex) + } + + private func selectPreviousItemInTable() { + let previousIndex = max(tableView.selectedRow - 1, 0) + tableView.selectRowIndexes(IndexSet(integer: previousIndex), byExtendingSelection: false) + tableView.scrollRowToVisible(previousIndex) + } + + deinit { + removeEventMonitor() + } +} + +extension ItemBoxWindowController: NSTableViewDataSource { + public func numberOfRows(in tableView: NSTableView) -> Int { + return items.count + } +} + +extension ItemBoxWindowController: NSTableViewDelegate { +// public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { +// items[row].view +// } + + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let cellIdentifier = NSUserInterfaceItemIdentifier("CustomCell") + var cell = tableView.makeView(withIdentifier: cellIdentifier, owner: nil) as? CustomTableCellView + + if cell == nil { + cell = CustomTableCellView(frame: .zero) + cell?.identifier = cellIdentifier + } + + // Remove any existing subviews + cell?.subviews.forEach { $0.removeFromSuperview() } + + let itemView = items[row].view + cell?.addSubview(itemView) + itemView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + itemView.topAnchor.constraint(equalTo: cell!.topAnchor), + itemView.leadingAnchor.constraint(equalTo: cell!.leadingAnchor, constant: 4), + itemView.trailingAnchor.constraint(equalTo: cell!.trailingAnchor, constant: -4), + itemView.bottomAnchor.constraint(equalTo: cell!.bottomAnchor) + ]) + + return cell + } +} + +private class CustomTableCellView: NSTableCellView { + private let backgroundView = NSView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + + backgroundView.wantsLayer = true + backgroundView.layer?.cornerRadius = 4 + addSubview(backgroundView, positioned: .below, relativeTo: nil) + + backgroundView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + override var backgroundStyle: NSView.BackgroundStyle { + didSet { + updateBackgroundColor() + } + } + + private func updateBackgroundColor() { + switch backgroundStyle { + case .normal: + backgroundView.layer?.backgroundColor = NSColor.clear.cgColor + case .emphasized: + backgroundView.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor + @unknown default: + backgroundView.layer?.backgroundColor = NSColor.clear.cgColor + } + } +} From a0e6e474db9a56d0eabc4e41af46b84ca876d7c1 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 26 Oct 2024 02:22:03 -0700 Subject: [PATCH 2/9] Updates --- .../TextView/TextView+ItemBox.swift | 181 +++++++----------- 1 file changed, 70 insertions(+), 111 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift index ee22c2d9..aebd1e0a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI +import LanguageServerProtocol public protocol ItemBoxEntry { var view: NSView { get } @@ -14,20 +15,22 @@ public protocol ItemBoxEntry { public final class ItemBoxWindowController: NSWindowController { + /// Default size of the window when opened public static let DEFAULT_SIZE = NSSize(width: 300, height: 212) + /// The items to be displayed in the window public var items: [any ItemBoxEntry] = [] { - didSet { - updateItems() - } + didSet { updateItems() } } private let tableView = NSTableView() private let scrollView = NSScrollView() + /// An event monitor for keyboard events private var localEventMonitor: Any? + /// Whether the ItemBox window is visbile public var isVisible: Bool { - return window?.isVisible ?? false + window?.isVisible ?? false } public init() { @@ -56,9 +59,9 @@ public final class ItemBoxWindowController: NSWindowController { window.contentView?.layer?.backgroundColor = CGColor( srgbRed: 31.0 / 255.0, green: 31.0 / 255.0, blue: 36.0 / 255.0, alpha: 1.0 ) - window.contentView?.layer?.cornerRadius = 8 + window.contentView?.layer?.cornerRadius = 8.5 window.contentView?.layer?.borderWidth = 1 - window.contentView?.layer?.borderColor = NSColor.gray.withAlphaComponent(0.4).cgColor + window.contentView?.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor let innerShadow = NSShadow() innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) innerShadow.shadowOffset = NSSize(width: 0, height: -1) @@ -80,6 +83,7 @@ public final class ItemBoxWindowController: NSWindowController { setupEventMonitor() } + /// Opens the window as a child of another window public func showWindow(attachedTo parentWindow: NSWindow) { guard let window = self.window else { return } parentWindow.addChildWindow(window, ordered: .above) @@ -97,6 +101,7 @@ public final class ItemBoxWindowController: NSWindowController { self.show() } + /// Close the window public override func close() { guard isVisible else { return } removeEventMonitor() @@ -108,45 +113,43 @@ public final class ItemBoxWindowController: NSWindowController { tableView.dataSource = self tableView.headerView = nil tableView.backgroundColor = .clear - tableView.intercellSpacing = .zero - tableView.selectionHighlightStyle = .none - tableView.backgroundColor = .clear - tableView.enclosingScrollView?.drawsBackground = false - tableView.rowHeight = 24 - + tableView.intercellSpacing = NSSize.zero + tableView.allowsEmptySelection = false + tableView.selectionHighlightStyle = .regular + tableView.headerView = nil + tableView.style = .plain + tableView.usesAutomaticRowHeights = false + tableView.rowSizeStyle = .custom + tableView.rowHeight = 21 + tableView.gridStyleMask = [] let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) column.width = ItemBoxWindowController.DEFAULT_SIZE.width tableView.addTableColumn(column) scrollView.documentView = tableView scrollView.hasVerticalScroller = true - scrollView.verticalScroller?.controlSize = .large + scrollView.verticalScroller = NoSlotScroller() + scrollView.scrollerStyle = .overlay scrollView.autohidesScrollers = true + scrollView.drawsBackground = false scrollView.automaticallyAdjustsContentInsets = false - scrollView.contentInsets = NSEdgeInsetsZero + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.verticalScrollElasticity = .allowed + scrollView.contentInsets = NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) window?.contentView?.addSubview(scrollView) - scrollView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: window!.contentView!.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: window!.contentView!.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: window!.contentView!.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: window!.contentView!.bottomAnchor) - ]) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: window!.contentView!.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: window!.contentView!.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: window!.contentView!.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: window!.contentView!.bottomAnchor) + ]) } private func updateItems() { tableView.reloadData() } - public func tableViewSelectionDidChange(_ notification: Notification) { - tableView.enumerateAvailableRowViews { (rowView, row) in - if let cellView = rowView.view(atColumn: 0) as? CustomTableCellView { - cellView.backgroundStyle = tableView.selectedRow == row ? .emphasized : .normal - } - } - } - private func setupEventMonitor() { localEventMonitor = NSEvent.addLocalMonitorForEvents( matching: [.keyDown, .leftMouseDown, .rightMouseDown] @@ -158,13 +161,10 @@ public final class ItemBoxWindowController: NSWindowController { switch event.keyCode { case 53: // Escape key self.close() - case 125: // Down arrow - self.selectNextItemInTable() - return nil - case 126: // Up arrow - self.selectPreviousItemInTable() + case 125, 126: // Down Arrow and Up Arrow + self.tableView.keyDown(with: event) return nil - case 36: // Return key + case 36, 48: // Return and Tab key return nil default: break @@ -189,18 +189,6 @@ public final class ItemBoxWindowController: NSWindowController { } } - private func selectNextItemInTable() { - let nextIndex = min(tableView.selectedRow + 1, items.count - 1) - tableView.selectRowIndexes(IndexSet(integer: nextIndex), byExtendingSelection: false) - tableView.scrollRowToVisible(nextIndex) - } - - private func selectPreviousItemInTable() { - let previousIndex = max(tableView.selectedRow - 1, 0) - tableView.selectRowIndexes(IndexSet(integer: previousIndex), byExtendingSelection: false) - tableView.scrollRowToVisible(previousIndex) - } - deinit { removeEventMonitor() } @@ -213,79 +201,50 @@ extension ItemBoxWindowController: NSTableViewDataSource { } extension ItemBoxWindowController: NSTableViewDelegate { -// public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { -// items[row].view -// } - public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let cellIdentifier = NSUserInterfaceItemIdentifier("CustomCell") - var cell = tableView.makeView(withIdentifier: cellIdentifier, owner: nil) as? CustomTableCellView - - if cell == nil { - cell = CustomTableCellView(frame: .zero) - cell?.identifier = cellIdentifier - } - - // Remove any existing subviews - cell?.subviews.forEach { $0.removeFromSuperview() } - - let itemView = items[row].view - cell?.addSubview(itemView) - itemView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - itemView.topAnchor.constraint(equalTo: cell!.topAnchor), - itemView.leadingAnchor.constraint(equalTo: cell!.leadingAnchor, constant: 4), - itemView.trailingAnchor.constraint(equalTo: cell!.trailingAnchor, constant: -4), - itemView.bottomAnchor.constraint(equalTo: cell!.bottomAnchor) - ]) + items[row].view + } - return cell + public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + ItemBoxRowView() } } -private class CustomTableCellView: NSTableCellView { - private let backgroundView = NSView() +private class NoSlotScroller: NSScroller { + override class var isCompatibleWithOverlayScrollers: Bool { true } - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setup() + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { + // Don't draw the knob slot (the scrollbar background) } +} - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setup() { - wantsLayer = true - layerContentsRedrawPolicy = .onSetNeedsDisplay - - backgroundView.wantsLayer = true - backgroundView.layer?.cornerRadius = 4 - addSubview(backgroundView, positioned: .below, relativeTo: nil) - - backgroundView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - backgroundView.topAnchor.constraint(equalTo: topAnchor), - backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), - backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), - backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - } +private class ItemBoxRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + + // Create a rect that's inset from the edges and has proper padding + // TODO: We create a new selectionRect instead of using dirtyRect + // because there is a visual bug when holding down the arrow keys + // to select the first or last item that draws a clipped rectangular + // selection highlight shape instead of the whole rectangle. Replace + // this when it gets fixed. + let padding: CGFloat = 5 + let selectionRect = NSRect( + x: padding, + y: 0, + width: bounds.width - (padding * 2), + height: bounds.height + ) - override var backgroundStyle: NSView.BackgroundStyle { - didSet { - updateBackgroundColor() - } - } + let cornerRadius: CGFloat = 5 + let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) + let selectionColor = NSColor.gray.withAlphaComponent(0.19) - private func updateBackgroundColor() { - switch backgroundStyle { - case .normal: - backgroundView.layer?.backgroundColor = NSColor.clear.cgColor - case .emphasized: - backgroundView.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor - @unknown default: - backgroundView.layer?.backgroundColor = NSColor.clear.cgColor - } + context.setFillColor(selectionColor.cgColor) + path.fill() + context.restoreGState() } } From 838d939a2f5e1655931eddcf7b78a164e146a10b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 26 Oct 2024 03:07:55 -0700 Subject: [PATCH 3/9] Refactors --- .../TextView/TextView+ItemBox.swift | 170 +++++++++++------- 1 file changed, 101 insertions(+), 69 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift index aebd1e0a..42cf253c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift @@ -9,12 +9,17 @@ import AppKit import SwiftUI import LanguageServerProtocol +/// Represents an item that can be displayed in the ItemBox public protocol ItemBoxEntry { var view: NSView { get } } +private let WINDOW_PADDING: CGFloat = 5 + public final class ItemBoxWindowController: NSWindowController { + // MARK: - Properties + /// Default size of the window when opened public static let DEFAULT_SIZE = NSSize(width: 300, height: 212) @@ -23,54 +28,27 @@ public final class ItemBoxWindowController: NSWindowController { didSet { updateItems() } } - private let tableView = NSTableView() - private let scrollView = NSScrollView() - /// An event monitor for keyboard events - private var localEventMonitor: Any? - /// Whether the ItemBox window is visbile public var isVisible: Bool { window?.isVisible ?? false } - public init() { - let window = NSWindow( - contentRect: NSRect(origin: CGPoint.zero, size: ItemBoxWindowController.DEFAULT_SIZE), - styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], - backing: .buffered, - defer: false - ) + // MARK: - Private Properties - // Style window - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.isExcludedFromWindowsMenu = true - window.isReleasedWhenClosed = false - window.level = .popUpMenu - window.hasShadow = true - window.isOpaque = false - window.tabbingMode = .disallowed - window.hidesOnDeactivate = true - window.backgroundColor = .clear - window.minSize = ItemBoxWindowController.DEFAULT_SIZE + private let tableView = NSTableView() + private let scrollView = NSScrollView() - // Style the content with custom borders and colors - window.contentView?.wantsLayer = true - window.contentView?.layer?.backgroundColor = CGColor( - srgbRed: 31.0 / 255.0, green: 31.0 / 255.0, blue: 36.0 / 255.0, alpha: 1.0 - ) - window.contentView?.layer?.cornerRadius = 8.5 - window.contentView?.layer?.borderWidth = 1 - window.contentView?.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor - let innerShadow = NSShadow() - innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) - innerShadow.shadowOffset = NSSize(width: 0, height: -1) - innerShadow.shadowBlurRadius = 2 - window.contentView?.shadow = innerShadow + /// An event monitor for keyboard events + private var localEventMonitor: Any? + // MARK: - Initialization + + public init() { + let window = Self.makeWindow() super.init(window: window) - setupTableView() + configureTableView(tableView) + configureScrollView(scrollView) } required init?(coder: NSCoder) { @@ -85,18 +63,18 @@ public final class ItemBoxWindowController: NSWindowController { /// Opens the window as a child of another window public func showWindow(attachedTo parentWindow: NSWindow) { - guard let window = self.window else { return } + guard let window = window else { return } + parentWindow.addChildWindow(window, ordered: .above) window.orderFront(nil) // Close on window switch NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: parentWindow, - queue: .current - ) { [weak self] _ in - self?.close() - } + self, + selector: #selector(parentWindowDidResignKey), + name: NSWindow.didResignKeyNotification, + object: parentWindow + ) self.show() } @@ -108,24 +86,76 @@ public final class ItemBoxWindowController: NSWindowController { super.close() } - private func setupTableView() { + // MARK: - Private Methods + + private static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: ItemBoxWindowController.DEFAULT_SIZE), + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + configureWindow(window) + configureWindowContent(window) + return window + } + + private static func configureWindow(_ window: NSWindow) { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isExcludedFromWindowsMenu = true + window.isReleasedWhenClosed = false + window.level = .popUpMenu + window.hasShadow = true + window.isOpaque = false + window.tabbingMode = .disallowed + window.hidesOnDeactivate = true + window.backgroundColor = .clear + window.minSize = ItemBoxWindowController.DEFAULT_SIZE + } + + private static func configureWindowContent(_ window: NSWindow) { + guard let contentView = window.contentView else { return } + + contentView.wantsLayer = true + contentView.layer?.backgroundColor = CGColor( + srgbRed: 31.0 / 255.0, + green: 31.0 / 255.0, + blue: 36.0 / 255.0, + alpha: 1.0 + ) + contentView.layer?.cornerRadius = 8.5 + contentView.layer?.borderWidth = 1 + contentView.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor + + let innerShadow = NSShadow() + innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) + innerShadow.shadowOffset = NSSize(width: 0, height: -1) + innerShadow.shadowBlurRadius = 2 + contentView.shadow = innerShadow + } + + private func configureTableView(_ tableView: NSTableView) { tableView.delegate = self tableView.dataSource = self tableView.headerView = nil tableView.backgroundColor = .clear - tableView.intercellSpacing = NSSize.zero + tableView.intercellSpacing = .zero tableView.allowsEmptySelection = false tableView.selectionHighlightStyle = .regular - tableView.headerView = nil tableView.style = .plain tableView.usesAutomaticRowHeights = false tableView.rowSizeStyle = .custom tableView.rowHeight = 21 tableView.gridStyleMask = [] + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) column.width = ItemBoxWindowController.DEFAULT_SIZE.width tableView.addTableColumn(column) + } + private func configureScrollView(_ scrollView: NSScrollView) { scrollView.documentView = tableView scrollView.hasVerticalScroller = true scrollView.verticalScroller = NoSlotScroller() @@ -135,17 +165,23 @@ public final class ItemBoxWindowController: NSWindowController { scrollView.automaticallyAdjustsContentInsets = false scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.verticalScrollElasticity = .allowed - scrollView.contentInsets = NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) - window?.contentView?.addSubview(scrollView) + scrollView.contentInsets = NSEdgeInsets(top: WINDOW_PADDING, left: 0, bottom: WINDOW_PADDING, right: 0) + + guard let contentView = window?.contentView else { return } + contentView.addSubview(scrollView) NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: window!.contentView!.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: window!.contentView!.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: window!.contentView!.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: window!.contentView!.bottomAnchor) + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } + @objc private func parentWindowDidResignKey() { + close() + } + private func updateItems() { tableView.reloadData() } @@ -159,26 +195,26 @@ public final class ItemBoxWindowController: NSWindowController { switch event.type { case .keyDown: switch event.keyCode { - case 53: // Escape key + case 53: // Escape self.close() - case 125, 126: // Down Arrow and Up Arrow + return nil + case 125, 126: // Down/Up Arrow self.tableView.keyDown(with: event) return nil - case 36, 48: // Return and Tab key + case 36, 48: // Return/Tab return nil default: - break + return event } case .leftMouseDown, .rightMouseDown: // If we click outside the window, close the window if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { self.close() } + return event default: - break + return event } - - return event } } @@ -194,13 +230,11 @@ public final class ItemBoxWindowController: NSWindowController { } } -extension ItemBoxWindowController: NSTableViewDataSource { +extension ItemBoxWindowController: NSTableViewDataSource, NSTableViewDelegate { public func numberOfRows(in tableView: NSTableView) -> Int { return items.count } -} -extension ItemBoxWindowController: NSTableViewDelegate { public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { items[row].view } @@ -224,6 +258,7 @@ private class ItemBoxRowView: NSTableRowView { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() + defer { context.restoreGState() } // Create a rect that's inset from the edges and has proper padding // TODO: We create a new selectionRect instead of using dirtyRect @@ -231,20 +266,17 @@ private class ItemBoxRowView: NSTableRowView { // to select the first or last item that draws a clipped rectangular // selection highlight shape instead of the whole rectangle. Replace // this when it gets fixed. - let padding: CGFloat = 5 let selectionRect = NSRect( - x: padding, + x: WINDOW_PADDING, y: 0, - width: bounds.width - (padding * 2), + width: bounds.width - (WINDOW_PADDING * 2), height: bounds.height ) - let cornerRadius: CGFloat = 5 let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) let selectionColor = NSColor.gray.withAlphaComponent(0.19) context.setFillColor(selectionColor.cgColor) path.fill() - context.restoreGState() } } From 2c82a1735758714ce8e6db634dceb8e7af3ec2e5 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 17 Dec 2024 23:22:38 -0800 Subject: [PATCH 4/9] ItemBox updates --- .../TextView/TextView+ItemBox.swift | 288 ++++++++++++++++-- 1 file changed, 267 insertions(+), 21 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift index 42cf253c..6fbe5727 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift @@ -6,26 +6,34 @@ // import AppKit -import SwiftUI import LanguageServerProtocol +// DOCUMENTATION BAR BEHAVIOR: +// IF THE DOCUMENTATION BAR APPEARS WHEN SELECTING AN ITEM AND IT EXTENDS BELOW THE SCREEN, IT WILL FLIP THE DIRECTION OF THE ENTIRE WINDOW +// IF IT GETS FLIPPED AND THEN THE DOCUMENTATION BAR DISAPPEARS FOR EXAMPLE, IT WONT FLIP BACK EVEN IF THERES SPACE NOW + /// Represents an item that can be displayed in the ItemBox public protocol ItemBoxEntry { var view: NSView { get } } +/// Padding at top and bottom of the window private let WINDOW_PADDING: CGFloat = 5 public final class ItemBoxWindowController: NSWindowController { // MARK: - Properties - /// Default size of the window when opened - public static let DEFAULT_SIZE = NSSize(width: 300, height: 212) + public static var DEFAULT_SIZE: NSSize { + NSSize( + width: 256, // TODO: DOES MIN WIDTH DEPEND ON FONT SIZE? + height: rowsToWindowHeight(for: 1) + ) + } /// The items to be displayed in the window - public var items: [any ItemBoxEntry] = [] { - didSet { updateItems() } + public var items: [CompletionItem] = [] { + didSet { onItemsUpdated() } } /// Whether the ItemBox window is visbile @@ -33,22 +41,46 @@ public final class ItemBoxWindowController: NSWindowController { window?.isVisible ?? false } + public weak var delegate: ItemBoxDelegate? + // MARK: - Private Properties + /// Height of a single row + private static let ROW_HEIGHT: CGFloat = 21 + /// Maximum number of visible rows (8.5) + private static let MAX_VISIBLE_ROWS: CGFloat = 8.5 + private let tableView = NSTableView() private let scrollView = NSScrollView() + private let popover = NSPopover() + /// Tracks when the window is placed above the cursor + private var isWindowAboveCursor = false + + private let noItemsLabel: NSTextField = { + let label = NSTextField(labelWithString: "No Completions") + label.textColor = .secondaryLabelColor + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = true + // TODO: GET FONT SIZE FROM THEME + label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + return label + }() /// An event monitor for keyboard events private var localEventMonitor: Any? + + public static let itemSelectedNotification = NSNotification.Name("ItemBoxItemSelected") // MARK: - Initialization public init() { let window = Self.makeWindow() super.init(window: window) - - configureTableView(tableView) - configureScrollView(scrollView) + configureTableView() + configureScrollView() + setupNoItemsLabel() + configurePopover() } required init?(coder: NSCoder) { @@ -56,9 +88,10 @@ public final class ItemBoxWindowController: NSWindowController { } /// Opens the window of items - public func show() { - super.showWindow(nil) + private func show() { setupEventMonitor() + resetScrollPosition() + super.showWindow(nil) } /// Opens the window as a child of another window @@ -86,11 +119,62 @@ public final class ItemBoxWindowController: NSWindowController { super.close() } + /// Will constrain the window's frame to be within the visible screen + public func constrainWindowToScreenEdges(cursorRect: NSRect) { + guard let window = self.window, + let screenFrame = window.screen?.visibleFrame else { + return + } + + let windowSize = window.frame.size + let padding: CGFloat = 22 + var newWindowOrigin = NSPoint( + x: cursorRect.origin.x, + y: cursorRect.origin.y + ) + + // Keep the horizontal position within the screen and some padding + let minX = screenFrame.minX + padding + let maxX = screenFrame.maxX - windowSize.width - padding + + if newWindowOrigin.x < minX { + newWindowOrigin.x = minX + } else if newWindowOrigin.x > maxX { + newWindowOrigin.x = maxX + } + + // Check if the window will go below the screen + // We determine whether the window drops down or upwards by choosing which + // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` + if newWindowOrigin.y - windowSize.height < screenFrame.minY { + // If the cursor itself is below the screen, then position the window + // at the bottom of the screen with some padding + if newWindowOrigin.y < screenFrame.minY { + newWindowOrigin.y = screenFrame.minY + padding + } else { + // Place above the cursor + newWindowOrigin.y += cursorRect.height + } + + isWindowAboveCursor = true + window.setFrameOrigin(newWindowOrigin) + } else { + // If the window goes above the screen, position it below the screen with padding + let maxY = screenFrame.maxY - padding + if newWindowOrigin.y > maxY { + newWindowOrigin.y = maxY + } + + isWindowAboveCursor = false + window.setFrameTopLeftPoint(newWindowOrigin) + } + } + // MARK: - Private Methods private static func makeWindow() -> NSWindow { let window = NSWindow( - contentRect: NSRect(origin: .zero, size: ItemBoxWindowController.DEFAULT_SIZE), + contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], backing: .buffered, defer: false @@ -112,13 +196,14 @@ public final class ItemBoxWindowController: NSWindowController { window.tabbingMode = .disallowed window.hidesOnDeactivate = true window.backgroundColor = .clear - window.minSize = ItemBoxWindowController.DEFAULT_SIZE + window.minSize = Self.DEFAULT_SIZE } private static func configureWindowContent(_ window: NSWindow) { guard let contentView = window.contentView else { return } contentView.wantsLayer = true + // TODO: GET COLOR FROM THEME contentView.layer?.backgroundColor = CGColor( srgbRed: 31.0 / 255.0, green: 31.0 / 255.0, @@ -136,7 +221,7 @@ public final class ItemBoxWindowController: NSWindowController { contentView.shadow = innerShadow } - private func configureTableView(_ tableView: NSTableView) { + private func configureTableView() { tableView.delegate = self tableView.dataSource = self tableView.headerView = nil @@ -151,11 +236,10 @@ public final class ItemBoxWindowController: NSWindowController { tableView.gridStyleMask = [] let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) - column.width = ItemBoxWindowController.DEFAULT_SIZE.width tableView.addTableColumn(column) } - private func configureScrollView(_ scrollView: NSScrollView) { + private func configureScrollView() { scrollView.documentView = tableView scrollView.hasVerticalScroller = true scrollView.verticalScroller = NoSlotScroller() @@ -178,11 +262,52 @@ public final class ItemBoxWindowController: NSWindowController { ]) } + private func configurePopover() { +// popover.behavior = .transient +// popover.animates = true + + // Create and configure the popover content + let contentViewController = NSViewController() + let contentView = NSView() + contentView.translatesAutoresizingMaskIntoConstraints = false + + let textField = NSTextField(labelWithString: "Example Documentation\nThis is some example documentation text.") + textField.translatesAutoresizingMaskIntoConstraints = false + textField.lineBreakMode = .byWordWrapping + textField.preferredMaxLayoutWidth = 300 + textField.cell?.wraps = true + textField.cell?.isScrollable = false + + contentView.addSubview(textField) + + NSLayoutConstraint.activate([ + textField.topAnchor.constraint(equalTo: contentView.topAnchor), + textField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + textField.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + contentView.widthAnchor.constraint(equalToConstant: 300) + ]) + + contentViewController.view = contentView + popover.contentViewController = contentViewController + } + + private func setupNoItemsLabel() { + window?.contentView?.addSubview(noItemsLabel) + + NSLayoutConstraint.activate([ + noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor), + noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) + ]) + } + @objc private func parentWindowDidResignKey() { close() } - private func updateItems() { + private func onItemsUpdated() { + updateItemBoxWindowAndContents() + resetScrollPosition() tableView.reloadData() } @@ -201,23 +326,131 @@ public final class ItemBoxWindowController: NSWindowController { case 125, 126: // Down/Up Arrow self.tableView.keyDown(with: event) return nil + case 124: // Right Arrow +// handleRightArrow() + return event + case 123: // Left Arrow + return event case 36, 48: // Return/Tab + // TODO: TEMPORARY + let selectedItem = items[tableView.selectedRow] + self.delegate?.applyCompletionItem(selectedItem) + + if items.count > 0 { + var nextRow = tableView.selectedRow + if nextRow == items.count - 1 && items.count > 1 { + nextRow -= 1 + } + items.remove(at: tableView.selectedRow) + if nextRow < items.count { + tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) + tableView.scrollRowToVisible(nextRow) + } + } return nil default: return event } + case .leftMouseDown, .rightMouseDown: // If we click outside the window, close the window if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { self.close() } return event + default: return event } } } + private func handleRightArrow() { + guard let window = self.window, + let selectedRow = tableView.selectedRowIndexes.first, + selectedRow < items.count, + !popover.isShown else { + return + } + + // Get the rect of the selected row in window coordinates + let rowRect = tableView.rect(ofRow: selectedRow) + let rowRectInWindow = tableView.convert(rowRect, to: nil) + // Calculate the point where the popover should appear + let popoverPoint = NSPoint( + x: window.frame.maxX, + y: window.frame.minY + rowRectInWindow.midY + ) + popover.show( + relativeTo: NSRect(x: popoverPoint.x, y: popoverPoint.y, width: 1, height: 1), + of: window.contentView!, + preferredEdge: .maxX + ) + } + + /// Updates the item box window's height based on the number of items. + /// If there are no items, the default label will be displayed instead. + private func updateItemBoxWindowAndContents() { + guard let window = self.window else { + return + } + + noItemsLabel.isHidden = !items.isEmpty + scrollView.isHidden = items.isEmpty + + // Update window dimensions + let numberOfVisibleRows = min(CGFloat(items.count), Self.MAX_VISIBLE_ROWS) + let newHeight = items.count == 0 ? + Self.rowsToWindowHeight(for: 1) : // Height for 1 row when empty + Self.rowsToWindowHeight(for: numberOfVisibleRows) + + let currentFrame = window.frame + if isWindowAboveCursor { + // When window is above cursor, maintain the bottom position + let bottomY = currentFrame.minY + let newFrame = NSRect( + x: currentFrame.minX, + y: bottomY, + width: currentFrame.width, + height: newHeight + ) + window.setFrame(newFrame, display: true) + } else { + // When window is below cursor, maintain the top position + window.setContentSize(NSSize(width: currentFrame.width, height: newHeight)) + } + + // Dont allow vertical resizing + window.maxSize = NSSize(width: CGFloat.infinity, height: newHeight) + window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) + } + + /// Calculate the window height for a given number of rows. + private static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { + let wholeRows = floor(numberOfRows) + let partialRow = numberOfRows - wholeRows + + let baseHeight = ROW_HEIGHT * wholeRows + let partialHeight = partialRow > 0 ? ROW_HEIGHT * partialRow : 0 + + // Add window padding only for whole numbers + let padding = numberOfRows.truncatingRemainder(dividingBy: 1) == 0 ? WINDOW_PADDING * 2 : WINDOW_PADDING + + return baseHeight + partialHeight + padding + } + + private func resetScrollPosition() { + guard let clipView = scrollView.contentView as? NSClipView else { return } + + // Scroll to the top of the content + clipView.scroll(to: NSPoint(x: 0, y: -WINDOW_PADDING)) + + // Select the first item + if !items.isEmpty { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + private func removeEventMonitor() { if let monitor = localEventMonitor { NSEvent.removeMonitor(monitor) @@ -236,19 +469,28 @@ extension ItemBoxWindowController: NSTableViewDataSource, NSTableViewDelegate { } public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - items[row].view + (items[row] as? any ItemBoxEntry)?.view } public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { ItemBoxRowView() } + + public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + // Only allow selection through keyboard navigation or single clicks + let event = NSApp.currentEvent + if event?.type == .leftMouseDragged { + return false + } + return true + } } private class NoSlotScroller: NSScroller { override class var isCompatibleWithOverlayScrollers: Bool { true } override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { - // Don't draw the knob slot (the scrollbar background) + // Don't draw the knob slot (the background track behind the knob) } } @@ -263,9 +505,9 @@ private class ItemBoxRowView: NSTableRowView { // Create a rect that's inset from the edges and has proper padding // TODO: We create a new selectionRect instead of using dirtyRect // because there is a visual bug when holding down the arrow keys - // to select the first or last item that draws a clipped rectangular - // selection highlight shape instead of the whole rectangle. Replace - // this when it gets fixed. + // to select the first or last item, which draws a clipped + // rectangular highlight shape instead of the whole rectangle. + // Replace this when it gets fixed. let selectionRect = NSRect( x: WINDOW_PADDING, y: 0, @@ -280,3 +522,7 @@ private class ItemBoxRowView: NSTableRowView { path.fill() } } + +public protocol ItemBoxDelegate: AnyObject { + func applyCompletionItem(_ item: CompletionItem) +} From 2a1d12e4c23511361e632115ae63551f0b7f7bca Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 03:36:39 -0800 Subject: [PATCH 5/9] Small update --- .../TextView/TextView+ItemBox.swift | 67 +++++-------------- 1 file changed, 17 insertions(+), 50 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift index 6fbe5727..d065c08d 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift @@ -8,6 +8,7 @@ import AppKit import LanguageServerProtocol +// TODO: // DOCUMENTATION BAR BEHAVIOR: // IF THE DOCUMENTATION BAR APPEARS WHEN SELECTING AN ITEM AND IT EXTENDS BELOW THE SCREEN, IT WILL FLIP THE DIRECTION OF THE ENTIRE WINDOW // IF IT GETS FLIPPED AND THEN THE DOCUMENTATION BAR DISAPPEARS FOR EXAMPLE, IT WONT FLIP BACK EVEN IF THERES SPACE NOW @@ -61,7 +62,7 @@ public final class ItemBoxWindowController: NSWindowController { label.textColor = .secondaryLabelColor label.alignment = .center label.translatesAutoresizingMaskIntoConstraints = false - label.isHidden = true + label.isHidden = false // TODO: GET FONT SIZE FROM THEME label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) return label @@ -80,7 +81,6 @@ public final class ItemBoxWindowController: NSWindowController { configureTableView() configureScrollView() setupNoItemsLabel() - configurePopover() } required init?(coder: NSCoder) { @@ -234,7 +234,6 @@ public final class ItemBoxWindowController: NSWindowController { tableView.rowSizeStyle = .custom tableView.rowHeight = 21 tableView.gridStyleMask = [] - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) tableView.addTableColumn(column) } @@ -262,36 +261,6 @@ public final class ItemBoxWindowController: NSWindowController { ]) } - private func configurePopover() { -// popover.behavior = .transient -// popover.animates = true - - // Create and configure the popover content - let contentViewController = NSViewController() - let contentView = NSView() - contentView.translatesAutoresizingMaskIntoConstraints = false - - let textField = NSTextField(labelWithString: "Example Documentation\nThis is some example documentation text.") - textField.translatesAutoresizingMaskIntoConstraints = false - textField.lineBreakMode = .byWordWrapping - textField.preferredMaxLayoutWidth = 300 - textField.cell?.wraps = true - textField.cell?.isScrollable = false - - contentView.addSubview(textField) - - NSLayoutConstraint.activate([ - textField.topAnchor.constraint(equalTo: contentView.topAnchor), - textField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - textField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - textField.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - contentView.widthAnchor.constraint(equalToConstant: 300) - ]) - - contentViewController.view = contentView - popover.contentViewController = contentViewController - } - private func setupNoItemsLabel() { window?.contentView?.addSubview(noItemsLabel) @@ -325,28 +294,22 @@ public final class ItemBoxWindowController: NSWindowController { return nil case 125, 126: // Down/Up Arrow self.tableView.keyDown(with: event) - return nil + if self.isVisible { + return nil + } + return event case 124: // Right Arrow // handleRightArrow() + self.close() return event case 123: // Left Arrow + self.close() return event case 36, 48: // Return/Tab - // TODO: TEMPORARY + guard tableView.selectedRow >= 0 else { return event } let selectedItem = items[tableView.selectedRow] self.delegate?.applyCompletionItem(selectedItem) - - if items.count > 0 { - var nextRow = tableView.selectedRow - if nextRow == items.count - 1 && items.count > 1 { - nextRow -= 1 - } - items.remove(at: tableView.selectedRow) - if nextRow < items.count { - tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - tableView.scrollRowToVisible(nextRow) - } - } + self.close() return nil default: return event @@ -372,11 +335,8 @@ public final class ItemBoxWindowController: NSWindowController { !popover.isShown else { return } - - // Get the rect of the selected row in window coordinates let rowRect = tableView.rect(ofRow: selectedRow) let rowRectInWindow = tableView.convert(rowRect, to: nil) - // Calculate the point where the popover should appear let popoverPoint = NSPoint( x: window.frame.maxX, y: window.frame.minY + rowRectInWindow.midY @@ -425,6 +385,13 @@ public final class ItemBoxWindowController: NSWindowController { window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) } + @objc private func tableViewDoubleClick(_ sender: Any) { + guard tableView.clickedRow >= 0 else { return } + let selectedItem = items[tableView.clickedRow] + delegate?.applyCompletionItem(selectedItem) + self.close() + } + /// Calculate the window height for a given number of rows. private static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { let wholeRows = floor(numberOfRows) From 4409f0a66ec59d2f58437d56a116d79cfeb8353f Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 05:17:21 -0800 Subject: [PATCH 6/9] Split up ItemBoxWindowController --- .../ItemBoxWindowController+Window.swift} | 287 +----------------- .../ItemBox/ItemBoxWindowController.swift | 269 ++++++++++++++++ 2 files changed, 282 insertions(+), 274 deletions(-) rename Sources/CodeEditTextView/{TextView/TextView+ItemBox.swift => ItemBox/ItemBoxWindowController+Window.swift} (50%) create mode 100644 Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift diff --git a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift similarity index 50% rename from Sources/CodeEditTextView/TextView/TextView+ItemBox.swift rename to Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift index d065c08d..10dcc686 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ItemBox.swift +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift @@ -1,124 +1,11 @@ // -// TextView+ItemBox.swift +// ItemBoxWindowController+Window.swift // CodeEditTextView // -// Created by Abe Malla on 6/18/24. +// Created by Abe Malla on 12/22/24. // -import AppKit -import LanguageServerProtocol - -// TODO: -// DOCUMENTATION BAR BEHAVIOR: -// IF THE DOCUMENTATION BAR APPEARS WHEN SELECTING AN ITEM AND IT EXTENDS BELOW THE SCREEN, IT WILL FLIP THE DIRECTION OF THE ENTIRE WINDOW -// IF IT GETS FLIPPED AND THEN THE DOCUMENTATION BAR DISAPPEARS FOR EXAMPLE, IT WONT FLIP BACK EVEN IF THERES SPACE NOW - -/// Represents an item that can be displayed in the ItemBox -public protocol ItemBoxEntry { - var view: NSView { get } -} - -/// Padding at top and bottom of the window -private let WINDOW_PADDING: CGFloat = 5 - -public final class ItemBoxWindowController: NSWindowController { - - // MARK: - Properties - - public static var DEFAULT_SIZE: NSSize { - NSSize( - width: 256, // TODO: DOES MIN WIDTH DEPEND ON FONT SIZE? - height: rowsToWindowHeight(for: 1) - ) - } - - /// The items to be displayed in the window - public var items: [CompletionItem] = [] { - didSet { onItemsUpdated() } - } - - /// Whether the ItemBox window is visbile - public var isVisible: Bool { - window?.isVisible ?? false - } - - public weak var delegate: ItemBoxDelegate? - - // MARK: - Private Properties - - /// Height of a single row - private static let ROW_HEIGHT: CGFloat = 21 - /// Maximum number of visible rows (8.5) - private static let MAX_VISIBLE_ROWS: CGFloat = 8.5 - - private let tableView = NSTableView() - private let scrollView = NSScrollView() - private let popover = NSPopover() - /// Tracks when the window is placed above the cursor - private var isWindowAboveCursor = false - - private let noItemsLabel: NSTextField = { - let label = NSTextField(labelWithString: "No Completions") - label.textColor = .secondaryLabelColor - label.alignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - label.isHidden = false - // TODO: GET FONT SIZE FROM THEME - label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) - return label - }() - - /// An event monitor for keyboard events - private var localEventMonitor: Any? - - public static let itemSelectedNotification = NSNotification.Name("ItemBoxItemSelected") - - // MARK: - Initialization - - public init() { - let window = Self.makeWindow() - super.init(window: window) - configureTableView() - configureScrollView() - setupNoItemsLabel() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Opens the window of items - private func show() { - setupEventMonitor() - resetScrollPosition() - super.showWindow(nil) - } - - /// Opens the window as a child of another window - public func showWindow(attachedTo parentWindow: NSWindow) { - guard let window = window else { return } - - parentWindow.addChildWindow(window, ordered: .above) - window.orderFront(nil) - - // Close on window switch - NotificationCenter.default.addObserver( - self, - selector: #selector(parentWindowDidResignKey), - name: NSWindow.didResignKeyNotification, - object: parentWindow - ) - - self.show() - } - - /// Close the window - public override func close() { - guard isVisible else { return } - removeEventMonitor() - super.close() - } - +extension ItemBoxWindowController { /// Will constrain the window's frame to be within the visible screen public func constrainWindowToScreenEdges(cursorRect: NSRect) { guard let window = self.window, @@ -260,94 +147,7 @@ public final class ItemBoxWindowController: NSWindowController { scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } - - private func setupNoItemsLabel() { - window?.contentView?.addSubview(noItemsLabel) - - NSLayoutConstraint.activate([ - noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor), - noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) - ]) - } - - @objc private func parentWindowDidResignKey() { - close() - } - - private func onItemsUpdated() { - updateItemBoxWindowAndContents() - resetScrollPosition() - tableView.reloadData() - } - - private func setupEventMonitor() { - localEventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.keyDown, .leftMouseDown, .rightMouseDown] - ) { [weak self] event in - guard let self = self else { return event } - - switch event.type { - case .keyDown: - switch event.keyCode { - case 53: // Escape - self.close() - return nil - case 125, 126: // Down/Up Arrow - self.tableView.keyDown(with: event) - if self.isVisible { - return nil - } - return event - case 124: // Right Arrow -// handleRightArrow() - self.close() - return event - case 123: // Left Arrow - self.close() - return event - case 36, 48: // Return/Tab - guard tableView.selectedRow >= 0 else { return event } - let selectedItem = items[tableView.selectedRow] - self.delegate?.applyCompletionItem(selectedItem) - self.close() - return nil - default: - return event - } - - case .leftMouseDown, .rightMouseDown: - // If we click outside the window, close the window - if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { - self.close() - } - return event - - default: - return event - } - } - } - - private func handleRightArrow() { - guard let window = self.window, - let selectedRow = tableView.selectedRowIndexes.first, - selectedRow < items.count, - !popover.isShown else { - return - } - let rowRect = tableView.rect(ofRow: selectedRow) - let rowRectInWindow = tableView.convert(rowRect, to: nil) - let popoverPoint = NSPoint( - x: window.frame.maxX, - y: window.frame.minY + rowRectInWindow.midY - ) - popover.show( - relativeTo: NSRect(x: popoverPoint.x, y: popoverPoint.y, width: 1, height: 1), - of: window.contentView!, - preferredEdge: .maxX - ) - } - + /// Updates the item box window's height based on the number of items. /// If there are no items, the default label will be displayed instead. private func updateItemBoxWindowAndContents() { @@ -385,13 +185,16 @@ public final class ItemBoxWindowController: NSWindowController { window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) } - @objc private func tableViewDoubleClick(_ sender: Any) { - guard tableView.clickedRow >= 0 else { return } - let selectedItem = items[tableView.clickedRow] - delegate?.applyCompletionItem(selectedItem) - self.close() - } + private func configureNoItemsLabel() { + window?.contentView?.addSubview(noItemsLabel) + + NSLayoutConstraint.activate([ + noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor), + noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) + ]) + } + /// Calculate the window height for a given number of rows. private static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { let wholeRows = floor(numberOfRows) @@ -405,29 +208,6 @@ public final class ItemBoxWindowController: NSWindowController { return baseHeight + partialHeight + padding } - - private func resetScrollPosition() { - guard let clipView = scrollView.contentView as? NSClipView else { return } - - // Scroll to the top of the content - clipView.scroll(to: NSPoint(x: 0, y: -WINDOW_PADDING)) - - // Select the first item - if !items.isEmpty { - tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) - } - } - - private func removeEventMonitor() { - if let monitor = localEventMonitor { - NSEvent.removeMonitor(monitor) - localEventMonitor = nil - } - } - - deinit { - removeEventMonitor() - } } extension ItemBoxWindowController: NSTableViewDataSource, NSTableViewDelegate { @@ -452,44 +232,3 @@ extension ItemBoxWindowController: NSTableViewDataSource, NSTableViewDelegate { return true } } - -private class NoSlotScroller: NSScroller { - override class var isCompatibleWithOverlayScrollers: Bool { true } - - override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { - // Don't draw the knob slot (the background track behind the knob) - } -} - -private class ItemBoxRowView: NSTableRowView { - override func drawSelection(in dirtyRect: NSRect) { - guard isSelected else { return } - guard let context = NSGraphicsContext.current?.cgContext else { return } - - context.saveGState() - defer { context.restoreGState() } - - // Create a rect that's inset from the edges and has proper padding - // TODO: We create a new selectionRect instead of using dirtyRect - // because there is a visual bug when holding down the arrow keys - // to select the first or last item, which draws a clipped - // rectangular highlight shape instead of the whole rectangle. - // Replace this when it gets fixed. - let selectionRect = NSRect( - x: WINDOW_PADDING, - y: 0, - width: bounds.width - (WINDOW_PADDING * 2), - height: bounds.height - ) - let cornerRadius: CGFloat = 5 - let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) - let selectionColor = NSColor.gray.withAlphaComponent(0.19) - - context.setFillColor(selectionColor.cgColor) - path.fill() - } -} - -public protocol ItemBoxDelegate: AnyObject { - func applyCompletionItem(_ item: CompletionItem) -} diff --git a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift new file mode 100644 index 00000000..ba26333b --- /dev/null +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift @@ -0,0 +1,269 @@ +// +// TextView+ItemBox.swift +// CodeEditTextView +// +// Created by Abe Malla on 6/18/24. +// + +import AppKit +import LanguageServerProtocol + +/// Represents an item that can be displayed in the ItemBox +public protocol ItemBoxEntry { + var view: NSView { get } +} + +/// Padding at top and bottom of the window +private let WINDOW_PADDING: CGFloat = 5 + +public final class ItemBoxWindowController: NSWindowController { + + // MARK: - Properties + + public static var DEFAULT_SIZE: NSSize { + NSSize( + width: 256, // TODO: DOES MIN WIDTH DEPEND ON FONT SIZE? + height: rowsToWindowHeight(for: 1) + ) + } + + /// The items to be displayed in the window + public var items: [CompletionItem] = [] { + didSet { onItemsUpdated() } + } + + /// Whether the ItemBox window is visbile + public var isVisible: Bool { + window?.isVisible ?? false + } + + public weak var delegate: ItemBoxDelegate? + + // MARK: - Private Properties + + /// Height of a single row + private static let ROW_HEIGHT: CGFloat = 21 + /// Maximum number of visible rows (8.5) + private static let MAX_VISIBLE_ROWS: CGFloat = 8.5 + + private let tableView = NSTableView() + private let scrollView = NSScrollView() + private let popover = NSPopover() + /// Tracks when the window is placed above the cursor + private var isWindowAboveCursor = false + + private let noItemsLabel: NSTextField = { + let label = NSTextField(labelWithString: "No Completions") + label.textColor = .secondaryLabelColor + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = false + // TODO: GET FONT SIZE FROM THEME + label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + return label + }() + + /// An event monitor for keyboard events + private var localEventMonitor: Any? + + public static let itemSelectedNotification = NSNotification.Name("ItemBoxItemSelected") + + // MARK: - Initialization + + public init() { + let window = Self.makeWindow() + super.init(window: window) + configureTableView() + configureScrollView() + configureNoItemsLabel() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Opens the window of items + private func show() { + setupEventMonitor() + resetScrollPosition() + super.showWindow(nil) + } + + /// Opens the window as a child of another window + public func showWindow(attachedTo parentWindow: NSWindow) { + guard let window = window else { return } + + parentWindow.addChildWindow(window, ordered: .above) + window.orderFront(nil) + + // Close on window switch + NotificationCenter.default.addObserver( + self, + selector: #selector(parentWindowDidResignKey), + name: NSWindow.didResignKeyNotification, + object: parentWindow + ) + + self.show() + } + + /// Close the window + public override func close() { + guard isVisible else { return } + removeEventMonitor() + super.close() + } + + @objc private func parentWindowDidResignKey() { + close() + } + + private func onItemsUpdated() { + updateItemBoxWindowAndContents() + resetScrollPosition() + tableView.reloadData() + } + + private func setupEventMonitor() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .leftMouseDown, .rightMouseDown] + ) { [weak self] event in + guard let self = self else { return event } + + switch event.type { + case .keyDown: + return checkKeyDownEvents(event) + + case .leftMouseDown, .rightMouseDown: + // If we click outside the window, close the window + if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { + self.close() + } + return event + + default: + return event + } + } + } + + private func checkKeyDownEvents(_ event: NSEvent) -> NSEvent? { + switch event.keyCode { + case 53: // Escape + self.close() + return nil + case 125, 126: // Down/Up Arrow + self.tableView.keyDown(with: event) + if self.isVisible { + return nil + } + return event + case 124: // Right Arrow +// handleRightArrow() + self.close() + return event + case 123: // Left Arrow + self.close() + return event + case 36, 48: // Return/Tab + guard tableView.selectedRow >= 0 else { return event } + let selectedItem = items[tableView.selectedRow] + self.delegate?.applyCompletionItem(selectedItem) + self.close() + return nil + default: + return event + } + } + + private func handleRightArrow() { + guard let window = self.window, + let selectedRow = tableView.selectedRowIndexes.first, + selectedRow < items.count, + !popover.isShown else { + return + } + let rowRect = tableView.rect(ofRow: selectedRow) + let rowRectInWindow = tableView.convert(rowRect, to: nil) + let popoverPoint = NSPoint( + x: window.frame.maxX, + y: window.frame.minY + rowRectInWindow.midY + ) + popover.show( + relativeTo: NSRect(x: popoverPoint.x, y: popoverPoint.y, width: 1, height: 1), + of: window.contentView!, + preferredEdge: .maxX + ) + } + + @objc private func tableViewDoubleClick(_ sender: Any) { + guard tableView.clickedRow >= 0 else { return } + let selectedItem = items[tableView.clickedRow] + delegate?.applyCompletionItem(selectedItem) + self.close() + } + + private func resetScrollPosition() { + guard let clipView = scrollView.contentView as? NSClipView else { return } + + // Scroll to the top of the content + clipView.scroll(to: NSPoint(x: 0, y: -WINDOW_PADDING)) + + // Select the first item + if !items.isEmpty { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + + private func removeEventMonitor() { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + } + + deinit { + removeEventMonitor() + } +} + +private class NoSlotScroller: NSScroller { + override class var isCompatibleWithOverlayScrollers: Bool { true } + + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { + // Don't draw the knob slot (the background track behind the knob) + } +} + +private class ItemBoxRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + defer { context.restoreGState() } + + // Create a rect that's inset from the edges and has proper padding + // TODO: We create a new selectionRect instead of using dirtyRect + // because there is a visual bug when holding down the arrow keys + // to select the first or last item, which draws a clipped + // rectangular highlight shape instead of the whole rectangle. + // Replace this when it gets fixed. + let selectionRect = NSRect( + x: WINDOW_PADDING, + y: 0, + width: bounds.width - (WINDOW_PADDING * 2), + height: bounds.height + ) + let cornerRadius: CGFloat = 5 + let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) + let selectionColor = NSColor.gray.withAlphaComponent(0.19) + + context.setFillColor(selectionColor.cgColor) + path.fill() + } +} + +public protocol ItemBoxDelegate: AnyObject { + func applyCompletionItem(_ item: CompletionItem) +} From 09e7484a31ac723497b027533c0c716a73d68f4a Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 05:18:52 -0800 Subject: [PATCH 7/9] Lint --- .../ItemBox/ItemBoxWindowController+Window.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift index 10dcc686..21d57653 100644 --- a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift @@ -147,7 +147,7 @@ extension ItemBoxWindowController { scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } - + /// Updates the item box window's height based on the number of items. /// If there are no items, the default label will be displayed instead. private func updateItemBoxWindowAndContents() { @@ -185,7 +185,6 @@ extension ItemBoxWindowController { window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) } - private func configureNoItemsLabel() { window?.contentView?.addSubview(noItemsLabel) @@ -194,7 +193,7 @@ extension ItemBoxWindowController { noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) ]) } - + /// Calculate the window height for a given number of rows. private static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { let wholeRows = floor(numberOfRows) From 2dbf5e055ed16f302cb053363f6596ae7519246b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 05:30:49 -0800 Subject: [PATCH 8/9] Lint --- .../ItemBoxWindowController+Window.swift | 47 +++++++++++++++--- .../ItemBox/ItemBoxWindowController.swift | 49 ++++--------------- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift index 21d57653..65ef35a1 100644 --- a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift @@ -5,6 +5,8 @@ // Created by Abe Malla on 12/22/24. // +import AppKit + extension ItemBoxWindowController { /// Will constrain the window's frame to be within the visible screen public func constrainWindowToScreenEdges(cursorRect: NSRect) { @@ -59,7 +61,7 @@ extension ItemBoxWindowController { // MARK: - Private Methods - private static func makeWindow() -> NSWindow { + static func makeWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], @@ -72,7 +74,7 @@ extension ItemBoxWindowController { return window } - private static func configureWindow(_ window: NSWindow) { + static func configureWindow(_ window: NSWindow) { window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isExcludedFromWindowsMenu = true @@ -86,7 +88,7 @@ extension ItemBoxWindowController { window.minSize = Self.DEFAULT_SIZE } - private static func configureWindowContent(_ window: NSWindow) { + static func configureWindowContent(_ window: NSWindow) { guard let contentView = window.contentView else { return } contentView.wantsLayer = true @@ -108,7 +110,7 @@ extension ItemBoxWindowController { contentView.shadow = innerShadow } - private func configureTableView() { + func configureTableView() { tableView.delegate = self tableView.dataSource = self tableView.headerView = nil @@ -125,7 +127,7 @@ extension ItemBoxWindowController { tableView.addTableColumn(column) } - private func configureScrollView() { + func configureScrollView() { scrollView.documentView = tableView scrollView.hasVerticalScroller = true scrollView.verticalScroller = NoSlotScroller() @@ -150,7 +152,7 @@ extension ItemBoxWindowController { /// Updates the item box window's height based on the number of items. /// If there are no items, the default label will be displayed instead. - private func updateItemBoxWindowAndContents() { + func updateItemBoxWindowAndContents() { guard let window = self.window else { return } @@ -185,7 +187,7 @@ extension ItemBoxWindowController { window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) } - private func configureNoItemsLabel() { + func configureNoItemsLabel() { window?.contentView?.addSubview(noItemsLabel) NSLayoutConstraint.activate([ @@ -195,7 +197,7 @@ extension ItemBoxWindowController { } /// Calculate the window height for a given number of rows. - private static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { + static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { let wholeRows = floor(numberOfRows) let partialRow = numberOfRows - wholeRows @@ -231,3 +233,32 @@ extension ItemBoxWindowController: NSTableViewDataSource, NSTableViewDelegate { return true } } + +private class ItemBoxRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + defer { context.restoreGState() } + + // Create a rect that's inset from the edges and has proper padding + // TODO: We create a new selectionRect instead of using dirtyRect + // because there is a visual bug when holding down the arrow keys + // to select the first or last item, which draws a clipped + // rectangular highlight shape instead of the whole rectangle. + // Replace this when it gets fixed. + let selectionRect = NSRect( + x: WINDOW_PADDING, + y: 0, + width: bounds.width - (WINDOW_PADDING * 2), + height: bounds.height + ) + let cornerRadius: CGFloat = 5 + let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) + let selectionColor = NSColor.gray.withAlphaComponent(0.19) + + context.setFillColor(selectionColor.cgColor) + path.fill() + } +} diff --git a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift index ba26333b..81566ab7 100644 --- a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift @@ -14,7 +14,7 @@ public protocol ItemBoxEntry { } /// Padding at top and bottom of the window -private let WINDOW_PADDING: CGFloat = 5 +let WINDOW_PADDING: CGFloat = 5 public final class ItemBoxWindowController: NSWindowController { @@ -42,17 +42,17 @@ public final class ItemBoxWindowController: NSWindowController { // MARK: - Private Properties /// Height of a single row - private static let ROW_HEIGHT: CGFloat = 21 + static let ROW_HEIGHT: CGFloat = 21 /// Maximum number of visible rows (8.5) - private static let MAX_VISIBLE_ROWS: CGFloat = 8.5 + static let MAX_VISIBLE_ROWS: CGFloat = 8.5 - private let tableView = NSTableView() - private let scrollView = NSScrollView() - private let popover = NSPopover() + let tableView = NSTableView() + let scrollView = NSScrollView() + let popover = NSPopover() /// Tracks when the window is placed above the cursor - private var isWindowAboveCursor = false + var isWindowAboveCursor = false - private let noItemsLabel: NSTextField = { + let noItemsLabel: NSTextField = { let label = NSTextField(labelWithString: "No Completions") label.textColor = .secondaryLabelColor label.alignment = .center @@ -83,7 +83,7 @@ public final class ItemBoxWindowController: NSWindowController { } /// Opens the window of items - private func show() { + func show() { setupEventMonitor() resetScrollPosition() super.showWindow(nil) @@ -227,7 +227,7 @@ public final class ItemBoxWindowController: NSWindowController { } } -private class NoSlotScroller: NSScroller { +class NoSlotScroller: NSScroller { override class var isCompatibleWithOverlayScrollers: Bool { true } override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { @@ -235,35 +235,6 @@ private class NoSlotScroller: NSScroller { } } -private class ItemBoxRowView: NSTableRowView { - override func drawSelection(in dirtyRect: NSRect) { - guard isSelected else { return } - guard let context = NSGraphicsContext.current?.cgContext else { return } - - context.saveGState() - defer { context.restoreGState() } - - // Create a rect that's inset from the edges and has proper padding - // TODO: We create a new selectionRect instead of using dirtyRect - // because there is a visual bug when holding down the arrow keys - // to select the first or last item, which draws a clipped - // rectangular highlight shape instead of the whole rectangle. - // Replace this when it gets fixed. - let selectionRect = NSRect( - x: WINDOW_PADDING, - y: 0, - width: bounds.width - (WINDOW_PADDING * 2), - height: bounds.height - ) - let cornerRadius: CGFloat = 5 - let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) - let selectionColor = NSColor.gray.withAlphaComponent(0.19) - - context.setFillColor(selectionColor.cgColor) - path.fill() - } -} - public protocol ItemBoxDelegate: AnyObject { func applyCompletionItem(_ item: CompletionItem) } From ad66b302dbba253ae28c53d9bd057b004051053f Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 23 Dec 2024 14:35:05 -0800 Subject: [PATCH 9/9] Fix double click, fix width not reseting --- .../ItemBoxWindowController+Window.swift | 18 ++++++++++++++++-- .../ItemBox/ItemBoxWindowController.swift | 7 ------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift index 65ef35a1..4a93697e 100644 --- a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController+Window.swift @@ -123,10 +123,24 @@ extension ItemBoxWindowController { tableView.rowSizeStyle = .custom tableView.rowHeight = 21 tableView.gridStyleMask = [] + tableView.target = self + tableView.action = #selector(tableViewClicked(_:)) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) tableView.addTableColumn(column) } + @objc private func tableViewClicked(_ sender: Any?) { + if NSApp.currentEvent?.clickCount == 2 { + let row = tableView.selectedRow + guard row >= 0, row < items.count else { + return + } + let selectedItem = items[row] + delegate?.applyCompletionItem(selectedItem) + self.close() + } + } + func configureScrollView() { scrollView.documentView = tableView scrollView.hasVerticalScroller = true @@ -173,13 +187,13 @@ extension ItemBoxWindowController { let newFrame = NSRect( x: currentFrame.minX, y: bottomY, - width: currentFrame.width, + width: ItemBoxWindowController.DEFAULT_SIZE.width, height: newHeight ) window.setFrame(newFrame, display: true) } else { // When window is below cursor, maintain the top position - window.setContentSize(NSSize(width: currentFrame.width, height: newHeight)) + window.setContentSize(NSSize(width: ItemBoxWindowController.DEFAULT_SIZE.width, height: newHeight)) } // Dont allow vertical resizing diff --git a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift index 81566ab7..90737d17 100644 --- a/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift +++ b/Sources/CodeEditTextView/ItemBox/ItemBoxWindowController.swift @@ -196,13 +196,6 @@ public final class ItemBoxWindowController: NSWindowController { ) } - @objc private func tableViewDoubleClick(_ sender: Any) { - guard tableView.clickedRow >= 0 else { return } - let selectedItem = items[tableView.clickedRow] - delegate?.applyCompletionItem(selectedItem) - self.close() - } - private func resetScrollPosition() { guard let clipView = scrollView.contentView as? NSClipView else { return }