From 26b91ac604525f3e38007148f9c3cfa94febcbd8 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Sat, 12 Sep 2026 17:39:01 +0100 Subject: [PATCH 1/3] Explicitly mark our XPC helper as not being a UI app --- Hammerspoon 2.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Hammerspoon 2.xcodeproj/project.pbxproj b/Hammerspoon 2.xcodeproj/project.pbxproj index 1ec5a2657..f91833fcf 100644 --- a/Hammerspoon 2.xcodeproj/project.pbxproj +++ b/Hammerspoon 2.xcodeproj/project.pbxproj @@ -822,6 +822,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = HammerspoonOSAScriptHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = HammerspoonOSAScriptHelper; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; @@ -861,6 +862,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = HammerspoonOSAScriptHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = HammerspoonOSAScriptHelper; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; From 19e8bd4938bb263e97ebe8da4461ab0fb9f5f02d Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Sat, 12 Sep 2026 17:39:15 +0100 Subject: [PATCH 2/3] Revert Console window to using a Text field --- Hammerspoon 2/Windows/ConsoleView.swift | 46 ++++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/Hammerspoon 2/Windows/ConsoleView.swift b/Hammerspoon 2/Windows/ConsoleView.swift index 785ec2874..d03049a4a 100644 --- a/Hammerspoon 2/Windows/ConsoleView.swift +++ b/Hammerspoon 2/Windows/ConsoleView.swift @@ -40,6 +40,30 @@ struct ConsoleView: View { Array(logs.entries(minimumLevel: minimumLogLevel, searchString: searchString).suffix(maxRenderedEntries)) } + /// Renders all displayed entries as a single AttributedString (rather than one `Text` per + /// entry) so SwiftUI's `.textSelection(.enabled)` treats the whole log as one text container — + /// a `LazyVStack` of separate `Text` views can't support selection spanning multiple rows. + /// + /// Cached in state and rebuilt only when `displayedEntries` changes (see `.onChange` below), + /// rather than computed inline in `body` — `body` re-runs on every keystroke in the eval + /// field, and rebuilding up to `maxRenderedEntries` lines of AttributedString each time would + /// be wasteful. + @State var displayedLogText = AttributedString() + + private func rebuildDisplayedLogText() { + var result = AttributedString() + let entries = displayedEntries + for (index, entry) in entries.enumerated() { + var line = AttributedString(formatEntry(entry)) + line.foregroundColor = colorForLogType(entry.logType) + result += line + if index < entries.count - 1 { + result += AttributedString("\n") + } + } + displayedLogText = result + } + private func formatEntry(_ entry: HammerspoonLogEntry) -> String { let date = entry.date.formatted( .verbatim( @@ -170,26 +194,24 @@ struct ConsoleView: View { VStack { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(displayedEntries) { entry in - Text(formatEntry(entry)) - .multilineTextAlignment(.leading) - .foregroundColor(colorForLogType(entry.logType)) - .id(entry.id) - } - } - .textSelection(.enabled) - .fontDesign(.monospaced) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() + Text(displayedLogText) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .fontDesign(.monospaced) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() Color.clear .frame(height: 0) .id("logBottom") } .onChange(of: displayedEntries) { + rebuildDisplayedLogText() proxy.scrollTo("logBottom", anchor: .bottom) } + .task { + rebuildDisplayedLogText() + } } TextField(">", text: $evalString, selection: $textSelection, prompt: Text("Javascript: >"), axis: .vertical) From 2aa54d69937bbb59db3dee5a7be9f141549b0069 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Sat, 12 Sep 2026 17:47:30 +0100 Subject: [PATCH 3/3] Rebuild console log text whenever the user changes relevant system configuration --- Hammerspoon 2/Windows/ConsoleView.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Hammerspoon 2/Windows/ConsoleView.swift b/Hammerspoon 2/Windows/ConsoleView.swift index d03049a4a..3372aa212 100644 --- a/Hammerspoon 2/Windows/ConsoleView.swift +++ b/Hammerspoon 2/Windows/ConsoleView.swift @@ -29,6 +29,9 @@ struct ConsoleView: View { @State var showSaveError: Bool = false @Environment(\.dismissWindow) var dismissWindow + @Environment(\.locale) var locale + @Environment(\.timeZone) var timeZone + @Environment(\.calendar) var calendar @AppStorage("minimumLogLevel") var minimumLogLevel: HammerspoonLogType = .Debug @@ -44,10 +47,10 @@ struct ConsoleView: View { /// entry) so SwiftUI's `.textSelection(.enabled)` treats the whole log as one text container — /// a `LazyVStack` of separate `Text` views can't support selection spanning multiple rows. /// - /// Cached in state and rebuilt only when `displayedEntries` changes (see `.onChange` below), - /// rather than computed inline in `body` — `body` re-runs on every keystroke in the eval - /// field, and rebuilding up to `maxRenderedEntries` lines of AttributedString each time would - /// be wasteful. + /// Cached in state and rebuilt only when `displayedEntries`, `locale`, `timeZone`, or + /// `calendar` changes (see `.onChange` below), rather than computed inline in `body` — + /// `body` re-runs on every keystroke in the eval field, and rebuilding up to + /// `maxRenderedEntries` lines of AttributedString each time would be wasteful. @State var displayedLogText = AttributedString() private func rebuildDisplayedLogText() { @@ -68,7 +71,7 @@ struct ConsoleView: View { let date = entry.date.formatted( .verbatim( "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits):\(second: .twoDigits)", - locale: .autoupdatingCurrent, timeZone: .autoupdatingCurrent, calendar: .autoupdatingCurrent + locale: locale, timeZone: timeZone, calendar: calendar ) ) return "\(date) - \(entry.logType.asString): \(entry.msg)" @@ -209,6 +212,12 @@ struct ConsoleView: View { rebuildDisplayedLogText() proxy.scrollTo("logBottom", anchor: .bottom) } + // Timestamps are formatted with these, so a change while the console is open + // (e.g. the user changes time zone) must invalidate the cached text too — + // it won't happen to get rebuilt otherwise, since displayedEntries doesn't change. + .onChange(of: locale) { rebuildDisplayedLogText() } + .onChange(of: timeZone) { rebuildDisplayedLogText() } + .onChange(of: calendar) { rebuildDisplayedLogText() } .task { rebuildDisplayedLogText() }