diff --git a/Hammerspoon 2/Modules/hs.chooser/HSChooser.swift b/Hammerspoon 2/Modules/hs.chooser/HSChooser.swift index aef181606..98f89a069 100644 --- a/Hammerspoon 2/Modules/hs.chooser/HSChooser.swift +++ b/Hammerspoon 2/Modules/hs.chooser/HSChooser.swift @@ -207,6 +207,35 @@ import SwiftUI /// ``` @objc var selectionColor: HSColor? { get set } + /// Font size of each result row's main text, in points (default: `14`). Changing this + /// also rescales the ⌘-digit shortcut hint shown on the first ten rows, and changes the + /// row height (and therefore the panel height) to fit. Must be a positive, finite number; + /// invalid values are ignored (with a warning logged). + /// - Example: + /// ```js + /// chooser.textSize = 18 + /// ``` + @objc var textSize: Double { get set } + + /// Font size of each result row's subtext, in points (default: `12`). Changing this + /// also changes the row height (and therefore the panel height) to fit. Must be a + /// positive, finite number; invalid values are ignored (with a warning logged). + /// - Example: + /// ```js + /// chooser.subTextSize = 14 + /// ``` + @objc var subTextSize: Double { get set } + + /// Font size of the search field's typed text and placeholder, in points (default: `20`). + /// Changing this also rescales the search icon, and changes the search bar height + /// (and therefore the panel height) to fit. Must be a positive, finite number; invalid + /// values are ignored (with a warning logged). + /// - Example: + /// ```js + /// chooser.querySize = 24 + /// ``` + @objc var querySize: Double { get set } + // MARK: Lifecycle /// Show the chooser. @@ -389,6 +418,42 @@ import SwiftUI set { viewModel.selectionColor = newValue?.color } } + @objc var textSize: Double { + get { Double(viewModel.textSize) } + set { + guard newValue.isFinite && newValue > 0 else { + AKWarning("hs.chooser.textSize: value must be a positive, finite number (got \(newValue))") + return + } + viewModel.textSize = CGFloat(newValue) + viewModel.onContentSizeChange?(viewModel.expectedHeight()) + } + } + + @objc var subTextSize: Double { + get { Double(viewModel.subTextSize) } + set { + guard newValue.isFinite && newValue > 0 else { + AKWarning("hs.chooser.subTextSize: value must be a positive, finite number (got \(newValue))") + return + } + viewModel.subTextSize = CGFloat(newValue) + viewModel.onContentSizeChange?(viewModel.expectedHeight()) + } + } + + @objc var querySize: Double { + get { Double(viewModel.querySize) } + set { + guard newValue.isFinite && newValue > 0 else { + AKWarning("hs.chooser.querySize: value must be a positive, finite number (got \(newValue))") + return + } + viewModel.querySize = CGFloat(newValue) + viewModel.onContentSizeChange?(viewModel.expectedHeight()) + } + } + private var _onSelect: JSCallback? private var _onQueryChange: JSCallback? private var _onShow: JSCallback? diff --git a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserRowView.swift b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserRowView.swift index 25255114d..7298d77a7 100644 --- a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserRowView.swift +++ b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserRowView.swift @@ -27,13 +27,13 @@ struct ChooserRowView: View { VStack(alignment: .leading, spacing: 2) { Text(item.text) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: viewModel.textSize, weight: .medium)) .foregroundStyle(viewModel.textColor ?? Color.primary) .lineLimit(1) if let subText = item.subText { Text(subText) - .font(.system(size: 12)) + .font(.system(size: viewModel.subTextSize)) .foregroundStyle(viewModel.subTextColor ?? Color.secondary) .lineLimit(1) } @@ -43,11 +43,11 @@ struct ChooserRowView: View { if let shortcutDigit { Text("⌘\(shortcutDigit)") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: viewModel.shortcutSize, weight: .medium)) .foregroundStyle(viewModel.subTextColor ?? Color.secondary) } } - .frame(height: ChooserViewModel.rowHeight) + .frame(height: viewModel.rowHeight) .padding(.horizontal, 16) .background( isSelected diff --git a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserView.swift b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserView.swift index d7f9b17ae..0f493a7b2 100644 --- a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserView.swift +++ b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserView.swift @@ -48,7 +48,7 @@ struct ChooserView: View { private var searchBar: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") - .font(.system(size: 17, weight: .medium)) + .font(.system(size: viewModel.iconSize, weight: .medium)) .foregroundStyle(viewModel.placeholderColor ?? Color.secondary) TextField( viewModel.placeholder, @@ -57,7 +57,7 @@ struct ChooserView: View { prompt: Text(viewModel.placeholder).foregroundStyle(viewModel.placeholderColor ?? Color.secondary) ) .textFieldStyle(.plain) - .font(.system(size: 20)) + .font(.system(size: viewModel.querySize)) .foregroundStyle(viewModel.queryColor ?? Color.primary) .focused($searchFocused) .onChange(of: viewModel.isVisible) { _, visible in @@ -68,7 +68,7 @@ struct ChooserView: View { } } .padding(.horizontal, 16) - .frame(height: ChooserViewModel.searchBarHeight) + .frame(height: viewModel.searchBarHeight) } private var resultsList: some View { @@ -98,7 +98,7 @@ struct ChooserView: View { } } } - .frame(height: CGFloat(visibleCount) * ChooserViewModel.rowHeight) + .frame(height: CGFloat(visibleCount) * viewModel.rowHeight) .onChange(of: viewModel.selectedIndex) { _, newIndex in guard newIndex < viewModel.filteredChoices.count else { return } proxy.scrollTo(viewModel.filteredChoices[newIndex].id, anchor: .center) diff --git a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserViewModel.swift b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserViewModel.swift index d0c7805ba..cf8b920ae 100644 --- a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserViewModel.swift +++ b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserViewModel.swift @@ -64,20 +64,68 @@ final class ChooserViewModel { var placeholderColor: Color? = nil /// Background tint of the highlighted row. `nil` uses a translucent accent-color tint. var selectionColor: Color? = nil + /// Result row title font size, in points. + var textSize: CGFloat = 14 + /// Result row subtitle font size, in points. + var subTextSize: CGFloat = 12 + /// Search field font size, in points (also drives the placeholder's size). + var querySize: CGFloat = 20 + + /// Font size of the ⌘-digit shortcut hint shown on the first ten rows. Scales with + /// `textSize` (at the same ratio as the original fixed defaults: 12pt hint / 14pt title) + /// rather than being independently configurable, since it's a small annotation on the + /// title, not a standalone piece of text. + var shortcutSize: CGFloat { textSize * Self.shortcutToTextRatio } + /// Font size of the search field's magnifying glass icon. Scales with `querySize` (at + /// the same ratio as the original fixed defaults: 17pt icon / 20pt query) rather than + /// being independently configurable, since it's an adornment on the search field. + var iconSize: CGFloat { querySize * Self.iconToQueryRatio } /// Notified when the user types in the search field (not when set programmatically). @ObservationIgnored var onUserQueryChange: ((String) -> Void)? /// Notified when content size changes so the window frame can be updated. @ObservationIgnored var onContentSizeChange: ((CGFloat) -> Void)? - static let searchBarHeight: CGFloat = 56 static let separatorHeight: CGFloat = 1 - static let rowHeight: CGFloat = 52 + + private static let shortcutToTextRatio: CGFloat = 12.0 / 14.0 + private static let iconToQueryRatio: CGFloat = 17.0 / 20.0 + + /// Vertical space between the title and subtitle lines, matching ChooserRowView's + /// `VStack(alignment: .leading, spacing: 2)`. + private static let rowContentSpacing: CGFloat = 2 + /// Total top+bottom padding baked into a row, chosen to reproduce the original fixed + /// 52pt row height at the original fixed default sizes (14pt title / 12pt subtitle). + private static let rowVerticalPadding: CGFloat = 18 + /// Total top+bottom padding baked into the search bar, chosen to reproduce the original + /// fixed 56pt search bar height at the original fixed default size (20pt query). + private static let searchBarVerticalPadding: CGFloat = 32 + + /// The actual rendered line height of the system font at `size`/`weight`, used to size + /// rows and the search bar precisely instead of guessing from point size alone. + private static func lineHeight(size: CGFloat, weight: NSFont.Weight = .regular) -> CGFloat { + let font = NSFont.systemFont(ofSize: size, weight: weight) + return ceil(font.ascender - font.descender + font.leading) + } + + /// Height of the search bar, driven by `querySize`. + var searchBarHeight: CGFloat { + Self.lineHeight(size: querySize) + Self.searchBarVerticalPadding + } + + /// Height of a single result row, driven by `textSize` and `subTextSize`. Every row gets + /// this same height regardless of whether that particular item has a subtitle, so the + /// list stays visually uniform — matching the original fixed-height behaviour. + var rowHeight: CGFloat { + let titleLine = Self.lineHeight(size: textSize, weight: .medium) + let subTextLine = Self.lineHeight(size: subTextSize) + return titleLine + Self.rowContentSpacing + subTextLine + Self.rowVerticalPadding + } func expectedHeight() -> CGFloat { let count = filteredChoices.count - guard count > 0 else { return Self.searchBarHeight } + guard count > 0 else { return searchBarHeight } let visibleCount = min(count, visibleRows) - return Self.searchBarHeight + Self.separatorHeight + Self.rowHeight * CGFloat(visibleCount) + return searchBarHeight + Self.separatorHeight + rowHeight * CGFloat(visibleCount) } } diff --git a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserWindow.swift b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserWindow.swift index 34e11d263..fd79af9e0 100644 --- a/Hammerspoon 2/Modules/hs.chooser/Views/ChooserWindow.swift +++ b/Hammerspoon 2/Modules/hs.chooser/Views/ChooserWindow.swift @@ -21,7 +21,7 @@ final class ChooserPanel: NSPanel { onSelect: @escaping (Int?) -> Void ) { let s = screen ?? NSScreen.main ?? NSScreen.screens[0] - let frame = ChooserPanel.initialFrame(on: s, width: width, height: ChooserViewModel.searchBarHeight) + let frame = ChooserPanel.initialFrame(on: s, width: width, height: viewModel.searchBarHeight) super.init( contentRect: frame, diff --git a/Hammerspoon 2Tests/IntegrationTests/HSChooserIntegrationTests.swift b/Hammerspoon 2Tests/IntegrationTests/HSChooserIntegrationTests.swift index 48eb84f45..56f956588 100644 --- a/Hammerspoon 2Tests/IntegrationTests/HSChooserIntegrationTests.swift +++ b/Hammerspoon 2Tests/IntegrationTests/HSChooserIntegrationTests.swift @@ -233,6 +233,27 @@ struct HSChooserTests { harness.eval("var c = hs.chooser.create()") harness.expectTrue("c.selectionColor == null") } + + @Test("textSize defaults to 14") + func testTextSizeDefault() { + let harness = makeHarness() + harness.eval("var c = hs.chooser.create()") + harness.expectTrue("Math.abs(c.textSize - 14) < 0.01") + } + + @Test("subTextSize defaults to 12") + func testSubTextSizeDefault() { + let harness = makeHarness() + harness.eval("var c = hs.chooser.create()") + harness.expectTrue("Math.abs(c.subTextSize - 12) < 0.01") + } + + @Test("querySize defaults to 20") + func testQuerySizeDefault() { + let harness = makeHarness() + harness.eval("var c = hs.chooser.create()") + harness.expectTrue("Math.abs(c.querySize - 20) < 0.01") + } } // MARK: - Behaviour @@ -441,6 +462,98 @@ struct HSChooserTests { #expect(!harness.hasException) } + @Test("textSize setter updates value") + func testTextSizeSetter() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.textSize = 18 + """) + harness.expectTrue("Math.abs(c.textSize - 18) < 0.01") + #expect(!harness.hasException) + } + + @Test("textSize setter rejects non-finite and non-positive values") + func testTextSizeSetterRejectsInvalid() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.textSize = 18 + c.textSize = NaN + c.textSize = Infinity + c.textSize = 0 + c.textSize = -5 + """) + harness.expectTrue("Math.abs(c.textSize - 18) < 0.01") + #expect(!harness.hasException) + } + + @Test("subTextSize setter updates value") + func testSubTextSizeSetter() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.subTextSize = 16 + """) + harness.expectTrue("Math.abs(c.subTextSize - 16) < 0.01") + #expect(!harness.hasException) + } + + @Test("subTextSize setter rejects non-finite and non-positive values") + func testSubTextSizeSetterRejectsInvalid() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.subTextSize = 16 + c.subTextSize = NaN + c.subTextSize = Infinity + c.subTextSize = 0 + c.subTextSize = -5 + """) + harness.expectTrue("Math.abs(c.subTextSize - 16) < 0.01") + #expect(!harness.hasException) + } + + @Test("querySize setter updates value") + func testQuerySizeSetter() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.querySize = 26 + """) + harness.expectTrue("Math.abs(c.querySize - 26) < 0.01") + #expect(!harness.hasException) + } + + @Test("querySize setter rejects non-finite and non-positive values") + func testQuerySizeSetterRejectsInvalid() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.querySize = 26 + c.querySize = NaN + c.querySize = Infinity + c.querySize = 0 + c.querySize = -5 + """) + harness.expectTrue("Math.abs(c.querySize - 26) < 0.01") + #expect(!harness.hasException) + } + + @Test("changing font sizes while the chooser is visible does not throw") + func testFontSizeChangeWhileVisible() { + let harness = makeHarness() + harness.eval(""" + var c = hs.chooser.create() + c.setChoices([{text: "Option A"}, {text: "Option B"}]) + c.show() + c.textSize = 22 + c.subTextSize = 16 + c.querySize = 28 + """) + #expect(!harness.hasException) + } + @Test("visibleRows setter updates value") func testVisibleRowsSetter() { let harness = makeHarness()