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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,14 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
)
)
)
// Sized so the native split view opens with a readable sidebar and a comfortable grouped
// detail form. The user can still resize from here; the sidebar provides its own range.
let initialFrame = CGRect(x: 0, y: 0, width: 980, height: 700)
// Sized so the native split view opens with a readable sidebar, a comfortable grouped
// detail form, and room for the Home pane's status-card row to breathe. The user can still
// resize from here; the sidebar provides its own range.
let initialFrame = CGRect(x: 0, y: 0, width: 1060, height: 720)
let minSize = NSSize(width: 900, height: 560)
// Bump the autosave name to reset everyone onto the current default instead of restoring
// any narrower frame saved by the previous sidebar experiments.
let autosaveName = "CotabbySettingsWindowV6"
// any narrower frame saved before the Home redesign.
let autosaveName = "CotabbySettingsWindowV7"

let window = NSWindow(
contentRect: initialFrame,
Expand Down
7 changes: 7 additions & 0 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
welcomeCoordinator.presentPermissionReminderIfNeeded()
didStartServices = true
CotabbyLogger.app.info("All services started")

// Dev affordance in the spirit of `-cotabby-debug`: a menu-bar-only app has no scriptable
// path to its Settings window (the status item is unreachable from AppleScript), so UI
// work on Settings cannot be exercised by tooling without this. No-op unless passed.
if ProcessInfo.processInfo.arguments.contains("-cotabby-open-settings") {
settingsCoordinator.showSettings()
}
}

/// One-time default: enable Open at Login for every user (new and existing) the first time this
Expand Down
17 changes: 17 additions & 0 deletions Cotabby/Support/BundleVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

/// File overview:
/// Shared human-facing version text. The sidebar header and the Home hero both show the short
/// marketing version; formatting it in one place keeps the two surfaces from drifting apart.
/// (The About pane intentionally uses its own longer "Version X (build)" format.)
extension Bundle {
/// Short marketing version prefixed for display (e.g. "v1.0"), or nil when the bundle carries
/// no version string (some test hosts).
var cotabbyDisplayVersion: String? {
guard let shortVersion = object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!shortVersion.isEmpty else {
return nil
}
return "v\(shortVersion)"
}
}
213 changes: 213 additions & 0 deletions Cotabby/Support/SettingsSearchRanker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import Foundation

/// File overview:
/// Pure relevance ranking for Settings search. The old search was a flat `contains` filter in
/// declaration order, which made common queries feel arbitrary: "ghost" listed whichever item
/// happened to be declared first, a typo found nothing, and multi-word queries only matched when
/// one field contained the whole phrase. This ranker scores every item per query token across its
/// title, keywords, owning pane, and summary, so results come back ordered by how directly they
/// answer the query.
///
/// Lives in `Support/` as a pure rule: no SwiftUI, no app state, fully unit-testable. The UI layer
/// conforms its catalog type (`SettingsItem`) to `SettingsSearchable` and calls `rank`.
///
/// Scoring model, per query token (highest applicable tier wins per field, best field wins per
/// token):
/// - Title: exact > prefix > word prefix > substring > fuzzy subsequence.
/// - Keywords: same tiers, weighted below title so synonyms help without outranking direct hits.
/// - Pane label: lets "emoji" surface the whole Emoji pane's items.
/// - Summary: catches descriptive phrasing ("too big", "on every keystroke").
/// An item matches only when every token matches somewhere; token scores then sum, with a small
/// cohesion bonus when all tokens hit the title. Ties keep declaration order so results stay stable.
enum SettingsSearchRanker {
/// One scored item, exposed for tests and for callers that want to inspect relevance.
struct Match<Item> {
let item: Item
let score: Double
}

/// Items matching `query`, best first. Empty for a blank query.
static func rank<Item: SettingsSearchable>(_ query: String, in items: [Item]) -> [Item] {
matches(query, in: items).map(\.item)
}

/// Scored matches for `query`, best first. Empty for a blank query.
static func matches<Item: SettingsSearchable>(_ query: String, in items: [Item]) -> [Match<Item>] {
let tokens = tokenize(query)
guard !tokens.isEmpty else { return [] }

let joinedQuery = tokens.joined(separator: " ")
let scored: [(offset: Int, match: Match<Item>)] = items.enumerated().compactMap { offset, item in
guard let score = score(tokens: tokens, joinedQuery: joinedQuery, item: item) else { return nil }
return (offset, Match(item: item, score: score))
}

return scored
.sorted { lhs, rhs in
if lhs.match.score != rhs.match.score {
return lhs.match.score > rhs.match.score
}
return lhs.offset < rhs.offset
}
.map(\.match)
}

// MARK: - Scoring

/// Tier weights for one searchable field. `nil` disables a tier for that field.
private struct FieldWeights {
let exact: Double
let prefix: Double
let wordPrefix: Double
let substring: Double
let subsequence: Double?
}

private static let titleWeights = FieldWeights(
exact: 100, prefix: 90, wordPrefix: 80, substring: 60, subsequence: 25
)
private static let keywordWeights = FieldWeights(
exact: 70, prefix: 55, wordPrefix: 50, substring: 40, subsequence: 12
)
private static let groupWeights = FieldWeights(
exact: 35, prefix: 30, wordPrefix: 25, substring: 20, subsequence: nil
)
private static let summaryWeights = FieldWeights(
exact: 30, prefix: 30, wordPrefix: 30, substring: 18, subsequence: nil
)

/// Bonus when every query token lands in the title: "ghost size" should place
/// "Ghost Text Size" above items where the tokens are split across unrelated fields.
private static let fullTitleCohesionBonus: Double = 15

/// Bonus when the whole query IS the title. Per-token scoring alone can tie a short title
/// with a longer one that contains the same words ("Accept Word" vs "Accept Punctuation With
/// Word"); typing a row's exact name must always win.
private static let exactTitleBonus: Double = 40

private static func score(
tokens: [String],
joinedQuery: String,
item: some SettingsSearchable
) -> Double? {
let title = normalize(item.searchTitle)
let keywords = item.searchKeywords.map(normalize)
let group = normalize(item.searchGroupLabel)
let summary = normalize(item.searchSummary)

var total = 0.0
var titleHits = 0

for token in tokens {
var best = 0.0
var tokenHitTitle = false

if let titleScore = fieldScore(token: token, target: title, weights: titleWeights) {
best = titleScore
tokenHitTitle = true
}
for keyword in keywords {
if let keywordScore = fieldScore(token: token, target: keyword, weights: keywordWeights),
keywordScore > best {
best = keywordScore
tokenHitTitle = false
}
}
if let groupScore = fieldScore(token: token, target: group, weights: groupWeights),
groupScore > best {
best = groupScore
tokenHitTitle = false
}
if let summaryScore = fieldScore(token: token, target: summary, weights: summaryWeights),
summaryScore > best {
best = summaryScore
tokenHitTitle = false
}

guard best > 0 else { return nil }
total += best
if tokenHitTitle { titleHits += 1 }
}

if titleHits == tokens.count {
total += fullTitleCohesionBonus
}
if title == joinedQuery {
total += exactTitleBonus
}
return total
}

private static func fieldScore(token: String, target: String, weights: FieldWeights) -> Double? {
guard !target.isEmpty else { return nil }
if target == token { return weights.exact }
if target.hasPrefix(token) { return weights.prefix }
// Reverse prefix: the user typed past the target ("languages" vs the keyword "language",
// "screenshots" vs "screenshot"). Both sides must be substantial so a long token does not
// match every tiny word it happens to start with.
if token.count >= 4, target.count >= 4, token.hasPrefix(target) { return weights.prefix }
if words(in: target).contains(where: { word in
word.hasPrefix(token) || (token.count >= 4 && word.count >= 4 && token.hasPrefix(word))
}) {
return weights.wordPrefix
}
if target.contains(token) { return weights.substring }
// Subsequence matching is the typo net ("batery" -> "battery"). Short tokens are skipped:
// two letters are a subsequence of almost everything and would flood results with noise.
if let subsequenceWeight = weights.subsequence,
token.count >= 3,
isSubsequence(token, of: target) {
return subsequenceWeight
}
return nil
}

// MARK: - Text helpers

/// Lowercased, diacritic-folded comparison form so "café" and "cafe" meet in the middle.
private static func normalize(_ text: String) -> String {
text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.lowercased()
}

private static func words(in text: String) -> [String] {
text.split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init)
}

/// Query tokens: normalized words, capped so a pathological paste cannot turn scoring into
/// quadratic work across the catalog.
private static func tokenize(_ query: String) -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
return trimmed
.split(whereSeparator: { $0.isWhitespace })
.prefix(8)
.map { normalize(String($0)) }
.filter { !$0.isEmpty }
}

/// Two-pointer subsequence test: every character of `token` appears in `target` in order.
private static func isSubsequence(_ token: String, of target: String) -> Bool {
var tokenIndex = token.startIndex
for character in target {
guard tokenIndex < token.endIndex else { return true }
if token[tokenIndex] == character {
tokenIndex = token.index(after: tokenIndex)
}
}
return tokenIndex == token.endIndex
}
}

/// What the ranker needs to know about one searchable setting. Kept as a protocol so the pure
/// ranker never imports the UI catalog type that conforms to it.
protocol SettingsSearchable {
/// The row's visible title ("Ghost Text Size").
var searchTitle: String { get }
/// Synonyms and adjacent vocabulary a user might type instead of the title.
var searchKeywords: [String] { get }
/// The owning pane's label ("Appearance"), so pane-name queries surface its items.
var searchGroupLabel: String { get }
/// The one-line caption shown under the row, searched for descriptive phrasing.
var searchSummary: String { get }
}
39 changes: 39 additions & 0 deletions Cotabby/UI/Settings/Components/SettingsIconTile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import SwiftUI

/// File overview:
/// The System Settings-style icon tile: a white SF Symbol on a tinted, continuously rounded
/// square with a soft top-to-bottom gradient. One component drawn at three scales keeps the
/// sidebar, search results, and Home quick links visually related, so a category reads as the
/// same object everywhere it appears.
struct SettingsIconTile: View {
let systemImage: String
let tint: Color
/// Edge length of the tile. The symbol and corner radius scale from it so callers only
/// choose a size, never a matching radius/font pair.
var size: CGFloat = 22

var body: some View {
Image(systemName: systemImage)
.font(.system(size: size * 0.52, weight: .medium))
.foregroundStyle(.white)
// White symbols disappear into pale tints (yellow especially) without a touch of
// depth; the hairline shadow keeps the glyph legible on every tile color.
.shadow(color: .black.opacity(0.15), radius: 0.5, y: 0.5)
.frame(width: size, height: size)
.background(
RoundedRectangle(cornerRadius: size * 0.24, style: .continuous)
.fill(
LinearGradient(
colors: [tint.opacity(0.85), tint],
startPoint: .top,
endPoint: .bottom
)
)
)
.overlay(
RoundedRectangle(cornerRadius: size * 0.24, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 0.5)
)
.accessibilityHidden(true)
}
}
64 changes: 50 additions & 14 deletions Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ import SwiftUI
/// surfaces attention per pane: when a pane is in a degraded state (missing permission, runtime
/// unavailable) we render an inline callout above the form so the actionable surface lives next to
/// the controls that fix it.
///
/// Search arrival:
/// When search reveals a specific setting, the scaffold scrolls to the row carrying the matching
/// `.settingsItem(_:)` anchor. The row's own modifier renders the pulse; the scaffold only owns
/// the scroll, so panes stay declarative.
struct SettingsPaneScaffold<Content: View>: View {
let callout: SettingsPaneCallout?
@ViewBuilder let content: () -> Content

@Environment(\.settingsHighlightedItem) private var highlightedItem

init(
callout: SettingsPaneCallout? = nil,
@ViewBuilder content: @escaping () -> Content
Expand All @@ -23,22 +30,51 @@ struct SettingsPaneScaffold<Content: View>: View {
}

var body: some View {
ScrollView {
VStack(spacing: 0) {
if let callout {
SettingsCalloutView(callout: callout)
.padding(.horizontal, 20)
.padding(.top, 16)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
if let callout {
SettingsCalloutView(callout: callout)
.padding(.horizontal, 20)
.padding(.top, 16)
}
Form {
content()
}
.formStyle(.grouped)
// `.formStyle(.grouped)` only pads BEFORE a `Section` that has a header. Panes
// whose first section is header-less (General, About, Apps) would otherwise butt
// flush against the title bar. A fixed top inset gives every pane the same
// breathing room regardless of whether the first section carries a header.
.padding(.top, 12)
}
Form {
content()
}
.onAppear {
// The pane is rebuilt on every sidebar switch (`.id(selection)` in the container),
// so a search arrival lands here before rows have laid out. Two staggered attempts
// instead of one timed guess: the first lands once typical layout has settled, the
// second repairs the rare slow-machine case where layout finished late. `scrollTo`
// to an already-centered anchor is a visual no-op, so the repair pass is invisible
// whenever the first attempt worked.
guard let item = highlightedItem else { return }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(80))
withAnimation(.easeInOut(duration: 0.35)) {
proxy.scrollTo(item, anchor: .center)
}
try? await Task.sleep(for: .milliseconds(350))
withAnimation(.easeInOut(duration: 0.35)) {
proxy.scrollTo(item, anchor: .center)
}
}
}
Comment thread
FuJacob marked this conversation as resolved.
.onChange(of: highlightedItem) { _, item in
// Same-pane reveals (a second search while already on the pane) skip onAppear, so
// the scroll also rides the highlight change itself.
guard let item else { return }
withAnimation(.easeInOut(duration: 0.35)) {
proxy.scrollTo(item, anchor: .center)
}
.formStyle(.grouped)
// `.formStyle(.grouped)` only pads BEFORE a `Section` that has a header. Panes
// whose first section is header-less (General, About, Apps) would otherwise butt
// flush against the title bar. A fixed top inset gives every pane the same
// breathing room regardless of whether the first section carries a header.
.padding(.top, 12)
}
}
}
Expand Down
Loading