Compare commits
7 Commits
9f6aa1db39
...
7eb52bd1db
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eb52bd1db | |||
| 639167950e | |||
| 5ae4ce7e4b | |||
| ffc8f81067 | |||
| f3d173d245 | |||
| 3fd9519169 | |||
| fd010ac864 |
+1
-1
@@ -8,6 +8,6 @@ let package = Package(
|
||||
targets: [
|
||||
.target(name: "ConjureKit"),
|
||||
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]),
|
||||
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit"]),
|
||||
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ Search is case- and accent-insensitive. Ranking is exact name, full-name prefix,
|
||||
|
||||
Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code.
|
||||
|
||||
With an empty search, favorites appear first. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group.
|
||||
With an empty search, favorites appear first, separated from the remaining applications by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group.
|
||||
|
||||
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
|
||||
|
||||
@@ -116,7 +116,7 @@ This is not full Raycast parity: calendar/date/time-zone calculations, design un
|
||||
|
||||
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
|
||||
|
||||
- **Favorite / Remove Favorite** toggles the selected application's persisted favorite status, updates ranking immediately, and preserves the selection without recording a launch. Open remains the default action. Reset ranking clears usage but does not remove a favorite.
|
||||
- **Favorite / Remove Favorite** toggles the selected application's persisted favorite status, updates ranking immediately, and preserves the selection without recording a launch. Favorited results show a filled star immediately to the left of the **Application** label. Open remains the default action. Reset ranking clears usage but does not remove a favorite.
|
||||
- **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus. Its icon is the native monochrome Finder glyph; macOS versions without that symbol use Finder's application icon.
|
||||
- **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again.
|
||||
- **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application.
|
||||
@@ -143,11 +143,15 @@ Files are never permanently deleted by uninstall. Trash moves preserve file meta
|
||||
|
||||
The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline.
|
||||
|
||||
The scrolling result list extends 16 points beneath the transparent section-label and footer areas, behind their controls, and fades to transparent over 32 points at each edge. Matching content insets keep resting endpoint rows clear of the controls. The fade stays fixed to the viewport while results scroll; the search bar and footer controls remain crisp.
|
||||
|
||||
The launch pill uses a 20%-opaque dark-brown fill over the launcher's existing frosted background rather than adding a second, darker popover material. Its rounded orange outline and controls remain distinct.
|
||||
|
||||
The application icon uses the same orange `sparkles` SF Symbol on a self-contained, rounded dark-brown tile. `Scripts/icon.swift` renders every standard and Retina icon size, and `make build` packages the resulting `.icns` in the signed bundle for Finder and other system icon surfaces. Conjure remains an accessory app; adding an application icon does not add a Dock or app-switcher entry.
|
||||
|
||||
Settings shares the launcher's dark appearance, frosted HUD material, Sunset background glows, cream headings, and orange control accents. The material and tint extend behind the title bar, while controls stay below it. Native window controls and shortcut recording behavior remain available.
|
||||
|
||||
The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` using the native HUD material. This exposes macOS's blurred backdrop while retaining the warm palette and glows; the system material supplies an opaque fallback when Reduce Transparency is enabled. Both the panel and launch pill use native rounded alpha masks alongside layer clipping, keeping material inside its rounded outline and making the window shadow follow the same shape.
|
||||
The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` using the native HUD material. This exposes macOS's blurred backdrop while retaining the warm palette and glows; the system material supplies an opaque fallback when Reduce Transparency is enabled. The panel uses a native rounded alpha mask alongside layer clipping, keeping material inside its rounded outline and making the window shadow follow the same shape.
|
||||
|
||||
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import AppKit
|
||||
|
||||
@MainActor
|
||||
enum SunsetPalette {
|
||||
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.35)
|
||||
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.55)
|
||||
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
|
||||
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
|
||||
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
|
||||
|
||||
@@ -257,7 +257,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
tableView.setAccessibilityLabel("Applications")
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
let scrollView = LauncherScrollView()
|
||||
scrollView.documentView = tableView
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.autohidesScrollers = true
|
||||
@@ -266,6 +266,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.contentView.drawsBackground = false
|
||||
scrollView.horizontalScrollElasticity = .none
|
||||
scrollView.automaticallyAdjustsContentInsets = false
|
||||
scrollView.contentInsets = NSEdgeInsets(top: 16, left: 0, bottom: 16, right: 0)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12)
|
||||
statusLabel.textColor = SunsetPalette.secondaryText
|
||||
@@ -295,11 +297,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let returnKey = ShortcutIndicator("↵")
|
||||
let actionKeys = ShortcutIndicator("⌘", "K")
|
||||
|
||||
let actionPill = NSVisualEffectView()
|
||||
actionPill.material = .popover
|
||||
actionPill.blendingMode = .withinWindow
|
||||
actionPill.state = .active
|
||||
roundMaterialCorners(actionPill, radius: 18)
|
||||
let actionPill = NSView()
|
||||
actionPill.wantsLayer = true
|
||||
actionPill.layer?.cornerRadius = 18
|
||||
actionPill.layer?.borderWidth = 0.5
|
||||
for view in [openButton, returnKey, actionsButton, actionKeys] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -310,7 +310,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
footer.orientation = .horizontal
|
||||
footer.alignment = .centerY
|
||||
footer.spacing = 12
|
||||
for view in [header, sectionLabel, scrollView, emptyLabel, footer] {
|
||||
for view in [scrollView, header, sectionLabel, emptyLabel, footer] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(view)
|
||||
}
|
||||
@@ -323,10 +323,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
symbol.heightAnchor.constraint(equalToConstant: 22),
|
||||
sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16),
|
||||
sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||||
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 6),
|
||||
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: -10),
|
||||
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
|
||||
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
||||
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
|
||||
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: 8),
|
||||
emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
|
||||
emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
|
||||
emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16),
|
||||
@@ -350,7 +350,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
content.layer?.borderColor = border
|
||||
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
||||
actionPill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor
|
||||
actionPill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
||||
}
|
||||
window.initialFirstResponder = searchField
|
||||
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
|
||||
@@ -527,12 +527,25 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
resultCount
|
||||
}
|
||||
|
||||
private func spacingBeforeRow(_ row: Int) -> CGFloat {
|
||||
guard calculation == nil,
|
||||
searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
row > 0, results.indices.contains(row),
|
||||
history.isFavorite(results[row - 1]), !history.isFavorite(results[row]) else { return 0 }
|
||||
return 12
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
|
||||
LauncherRowView.rowHeight + spacingBeforeRow(row)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
|
||||
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
|
||||
?? makeCell(identifier: identifier)
|
||||
if let calculation {
|
||||
cell.textField?.stringValue = calculation.displayValue
|
||||
cell.textField?.setAccessibilityLabel(nil)
|
||||
cell.locationLabel.isHidden = true
|
||||
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
|
||||
: calculation.copyValue == nil ? "Error" : "Calculator"
|
||||
@@ -546,7 +559,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let location = locationLabels[application.id]
|
||||
cell.locationLabel.stringValue = location ?? ""
|
||||
cell.locationLabel.isHidden = location == nil
|
||||
cell.kindLabel.stringValue = "Application"
|
||||
let isFavorite = history.isFavorite(application)
|
||||
cell.kindLabel.attributedStringValue = isFavorite
|
||||
? ApplicationCell.favoriteKind : ApplicationCell.applicationKind
|
||||
cell.textField?.setAccessibilityLabel(isFavorite ? "\(application.name), Favorite" : nil)
|
||||
cell.imageView?.contentTintColor = nil
|
||||
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
|
||||
cell.toolTip = application.url.path
|
||||
@@ -560,6 +576,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let view = (tableView.makeView(withIdentifier: identifier, owner: self) as? LauncherRowView)
|
||||
?? LauncherRowView()
|
||||
view.identifier = identifier
|
||||
view.topSpacing = spacingBeforeRow(row)
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -729,11 +746,27 @@ final class ApplicationCell: NSTableCellView {
|
||||
private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor]
|
||||
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
|
||||
private static let timing = CAMediaTimingFunction(name: .easeOut)
|
||||
static let applicationKind = NSAttributedString(string: "Application", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.secondaryText
|
||||
])
|
||||
static let favoriteKind: NSAttributedString = {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
|
||||
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .regular)
|
||||
.applying(.init(paletteColors: [SunsetPalette.secondaryText])))
|
||||
let size = attachment.image?.size ?? .zero
|
||||
attachment.bounds = NSRect(x: 0, y: (NSFont.systemFont(ofSize: 14).capHeight - size.height) / 2,
|
||||
width: size.width, height: size.height)
|
||||
let title = NSMutableAttributedString(attachment: attachment)
|
||||
title.append(NSAttributedString(string: " "))
|
||||
title.append(applicationKind)
|
||||
return title
|
||||
}()
|
||||
|
||||
let locationLabel = NSTextField(labelWithString: "")
|
||||
let kindLabel = NSTextField(labelWithString: "Application")
|
||||
let numberHint = ShortcutIndicator(
|
||||
"1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1),
|
||||
"1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 0.5),
|
||||
fontSize: 10, keySize: 20
|
||||
)
|
||||
private let kindFade = CAGradientLayer()
|
||||
@@ -876,6 +909,33 @@ final class ApplicationCell: NSTableCellView {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LauncherScrollView: NSScrollView {
|
||||
private let edgeFade = CAGradientLayer()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
edgeFade.colors = [NSColor.clear.cgColor, NSColor.black.cgColor,
|
||||
NSColor.black.cgColor, NSColor.clear.cgColor]
|
||||
edgeFade.startPoint = CGPoint(x: 0.5, y: 0)
|
||||
edgeFade.endPoint = CGPoint(x: 0.5, y: 1)
|
||||
layer?.mask = edgeFade
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates its scroll view programmatically") }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
let fade = min(0.5, 32 / max(bounds.height, 1))
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
edgeFade.frame = bounds
|
||||
edgeFade.locations = [0, NSNumber(value: Double(fade)), NSNumber(value: Double(1 - fade)), 1]
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ApplicationTableView: NSTableView {
|
||||
var openSelection: (() -> Void)?
|
||||
@@ -1038,9 +1098,22 @@ final class LauncherRowView: NSTableRowView {
|
||||
static let textSize: CGFloat = 14
|
||||
static let iconSize: CGFloat = 22
|
||||
static let iconToTextSpacing: CGFloat = 12
|
||||
var topSpacing: CGFloat = 0 {
|
||||
didSet { needsLayout = true; needsDisplay = true }
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
for case let cell as ApplicationCell in subviews {
|
||||
cell.frame = NSRect(x: 0, y: topSpacing, width: bounds.width, height: bounds.height - topSpacing)
|
||||
}
|
||||
}
|
||||
|
||||
override func drawSelection(in dirtyRect: NSRect) {
|
||||
let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
|
||||
var selectionBounds = bounds
|
||||
selectionBounds.origin.y += topSpacing
|
||||
selectionBounds.size.height -= topSpacing
|
||||
let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
|
||||
SunsetPalette.selectionFill.setFill()
|
||||
selection.fill()
|
||||
let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
- [x] Add Favorite / Remove Favorite actions and persist favorites by application bundle.
|
||||
- Empty search: favorites first, then usage ranking within each group.
|
||||
- Typed search: match quality first, then favorites, launch count, recency, and alphabetical order.
|
||||
@@ -0,0 +1,18 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func favoriteSpacingLeavesApplicationContentAtNormalHeightAndResetsOnReuse() {
|
||||
_ = NSApplication.shared
|
||||
let row = LauncherRowView(frame: NSRect(x: 0, y: 0, width: 650, height: 50))
|
||||
let cell = ApplicationCell(frame: row.bounds)
|
||||
row.addSubview(cell)
|
||||
row.topSpacing = 12
|
||||
row.layoutSubtreeIfNeeded()
|
||||
#expect(cell.frame == NSRect(x: 0, y: 12, width: 650, height: 38))
|
||||
row.frame.size.height = 38
|
||||
row.topSpacing = 0
|
||||
row.layoutSubtreeIfNeeded()
|
||||
#expect(cell.frame == NSRect(x: 0, y: 0, width: 650, height: 38))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func launcherFadesStayAtViewportEdgesAfterScrollingAndResizing() throws {
|
||||
_ = NSApplication.shared
|
||||
let scroll = LauncherScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
|
||||
scroll.documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 600))
|
||||
for height in [120, 240] {
|
||||
scroll.frame.size.height = CGFloat(height)
|
||||
scroll.contentView.scroll(to: NSPoint(x: 0, y: 80))
|
||||
scroll.layoutSubtreeIfNeeded()
|
||||
let mask = try #require(scroll.layer?.mask)
|
||||
let context = try #require(CGContext(data: nil, width: 200, height: height,
|
||||
bitsPerComponent: 8, bytesPerRow: 800, space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
|
||||
mask.render(in: context)
|
||||
let pixels = try #require(context.data?.assumingMemoryBound(to: UInt8.self))
|
||||
func alpha(_ y: Int) -> UInt8 { pixels[y * 800 + 100 * 4 + 3] }
|
||||
#expect(alpha(0) < 20)
|
||||
#expect(alpha(height / 2) == 255)
|
||||
#expect(alpha(height - 1) < 20)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user