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
65 changes: 65 additions & 0 deletions Hammerspoon 2/Modules/hs.chooser/HSChooser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Comment thread
cmsj marked this conversation as resolved.
}
}

@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?
Expand Down
8 changes: 4 additions & 4 deletions Hammerspoon 2/Modules/hs.chooser/Views/ChooserRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Hammerspoon 2/Modules/hs.chooser/Views/ChooserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -68,7 +68,7 @@ struct ChooserView: View {
}
}
.padding(.horizontal, 16)
.frame(height: ChooserViewModel.searchBarHeight)
.frame(height: viewModel.searchBarHeight)
}

private var resultsList: some View {
Expand Down Expand Up @@ -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)
Expand Down
56 changes: 52 additions & 4 deletions Hammerspoon 2/Modules/hs.chooser/Views/ChooserViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
cmsj marked this conversation as resolved.
}
}
2 changes: 1 addition & 1 deletion Hammerspoon 2/Modules/hs.chooser/Views/ChooserWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions Hammerspoon 2Tests/IntegrationTests/HSChooserIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +487 to +488

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Assignment exceptions go undetected

expectTrue starts a new JavaScript evaluation, which clears the exception recorded by the assignment block before hasException checks it. If an invalid assignment throws but leaves the size unchanged, this test still passes, so it does not verify that invalid values are ignored without an exception. Check hasException immediately after the assignment block. The subtext and query tests have the same pattern.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Hammerspoon 2Tests/IntegrationTests/HSChooserIntegrationTests.swift
Line: 487-488

Comment:
**Assignment exceptions go undetected**

`expectTrue` starts a new JavaScript evaluation, which clears the exception recorded by the assignment block before `hasException` checks it. If an invalid assignment throws but leaves the size unchanged, this test still passes, so it does not verify that invalid values are ignored without an exception. Check `hasException` immediately after the assignment block. The subtext and query tests have the same pattern.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

@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)
Comment thread
cmsj marked this conversation as resolved.
}

@Test("visibleRows setter updates value")
func testVisibleRowsSetter() {
let harness = makeHarness()
Expand Down
Loading