diff --git a/hammerspoon/.hammerspoon/CLAUDE.md b/hammerspoon/.hammerspoon/CLAUDE.md deleted file mode 100644 index eb4a7c4..0000000 --- a/hammerspoon/.hammerspoon/CLAUDE.md +++ /dev/null @@ -1,104 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -### Development Commands -- **Test**: `make test` - Run all test suites for the alert system -- **Format**: `make format` - Format Lua code using stylua (config at `../../.stylua.toml`) -- **Reload Configuration**: Hammerspoon automatically reloads on file changes via ReloadConfiguration spoon - -### Hammerspoon CLI Commands -- **Run specific test**: `hs -c "dofile(hs.configdir .. '/tests/test_basic_alerts.lua').run()"` -- **Debug active alerts**: `hs -c "require('modules.helpers'):debug_active_alerts()"` -- **Manual config reload**: `hs -c "hs.reload()"` -- **Read console output**: `hs -c 'print(hs.console.getConsole())'` -- **Read recent console**: `hs -c 'print(hs.console.getConsole())' | tail -10` - -### CLI Development Tips -- **Module reloading**: After editing Lua files, always run `hs -c "hs.reload()"` to reload the configuration -- **IPC errors are normal**: When reloading, you may see "message port was invalidated" errors - these are expected -- **Testing modules**: Use `local wm = require('modules.wm'); wm:alert_window_state()` pattern for testing -- **Crash recovery**: If `hs` command crashes with NSDestinationInvalidException, the operation may still succeed -- **Print statements**: Debug prints may fail due to IPC issues during reload, but the actual code execution continues -- **Window access**: `hs.window.allWindows()` may return windows that fail `isValid()` checks - use `pcall()` for safety - -## Architecture Overview - -This is a Hammerspoon configuration focused on window management and system automation with a custom alert system. - -### Core Components - -**Window management system (`modules/wm/`)** -- Sophisticated tiling window manager with 5 predefined layouts -- Split into submodules: `init.lua` (config/bindings/lifecycle), `geometry.lua` (built-in unit rects), `state.lua` (persistent per-layout window state), `actions.lua` (window operations), `util.lua` (pure helpers) -- Persistent state management with JSON serialization (`~/.hammerspoon/_wm.spoon.state.json`), auto-saved on shutdown/reload and restored on init -- Per-application window geometry tracking across layouts -- Built-in geometries: full, padded (left/right/center), skinny, PiP positions -- Handles Firefox AX compatibility issues by disabling `AXEnhancedUserInterface` (cached per application) -- Diagnostics use `hs.logger` (`wm.log`); raise the level with `require('modules.wm').log.setLogLevel('debug')` - -**Alert system (`modules/helpers.lua`)** -- Canvas-based alert system with gradient backgrounds and animations -- Four styled alert types: info, success, warn, error -- Auto-stacking of multiple alerts with proper vertical spacing -- Asset management for icons (heroicons) -- String manipulation utilities (center, lpad, rpad) - -**Plugin management (`modules/plugins.lua`)** -- Spoon ecosystem integration (ReloadConfiguration, LookupSelection, AppLauncher, KSheet, Seal) -- Application launcher with custom search paths -- Seal plugin with calculator, screen capture, and user actions - -**System utilities** -- Caffeine toggle (`modules/caffeine.lua`) - Display sleep prevention -- Path watchers (`modules/watchers.lua`) - Currently disabled Downloads monitoring - -### Key Bindings - -**Global Modifiers:** -- `HYPER = { "cmd", "ctrl" }` -- `HYPER_SHIFT = { "cmd", "ctrl", "shift" }` - -**Window Management:** -- `Cmd+Ctrl+[1-5]`: Switch to layout 1-5 -- `Cmd+Shift+h/l`: Cycle focused window through layout positions -- `Cmd+Shift+-`: Save window state -- `Cmd+Shift+=`: Restore window state -- `Cmd+Shift+/`: Show current layout state -- `Cmd+Ctrl+h/l`: Move window one screen west/east - -**System Controls:** -- `Hyper+0`: Toggle Caffeine (display sleep) -- `Cmd+p`: Seal launcher -- `Hyper+d`: Dictionary lookup -- `Hyper+/`: Keyboard shortcuts reference - -### State management - -**Window State Persistence:** -- Layout-specific window positions stored in JSON format -- Automatic cleanup of stale window references -- Per-window geometry index tracking across multiple layouts - -**Alert System:** -- Active alerts table with canvas references and timers -- Automatic positioning with collision detection -- Configurable animation duration (0.3s) and display duration (3.0s) - -### Testing framework - -The codebase includes a comprehensive test suite in `/tests/`: -- `test_basic_alerts.lua`: Basic alert functionality and auto-dismissal -- `test_stacking.lua`: Multiple alert stacking behavior -- `test_manual_dismissal.lua`: Manual dismissal functionality -- `test_compatibility.lua`: Legacy compatibility testing -- `test_window_state.lua`: Window manager state persistence and pure-logic tests - -### Configuration notes - -- Animation duration set to 0 for instant window movements -- Frame correction disabled by default (slow path; the AX fix covers Firefox), opt back in with `wm.config.set_frame_correctness = true` -- Standard window detection prevents resizing of modal dialogs and floating windows -- Asset loading from `/assets/heroicons-*` directories with PNG icons diff --git a/hammerspoon/.hammerspoon/Makefile b/hammerspoon/.hammerspoon/Makefile index bd7ac71..5ce45ca 100644 --- a/hammerspoon/.hammerspoon/Makefile +++ b/hammerspoon/.hammerspoon/Makefile @@ -1,20 +1,25 @@ .DEFAULT_GOAL := test .PHONY: test format native preview-glass -NATIVE_BINARY := native/.build/glass-toast +NATIVE_BINARIES := native/.build/glass-toast native/.build/native-chooser -native: $(NATIVE_BINARY) +native: $(NATIVE_BINARIES) -$(NATIVE_BINARY): native/GlassToast.swift +native/.build/glass-toast: native/GlassToast.swift +native/.build/native-chooser: native/NativeChooser.swift + +$(NATIVE_BINARIES): native/GlassBackground.swift mkdir -p native/.build - xcrun swiftc -O -target "$$(uname -m)-apple-macosx13.0" -framework AppKit $< -o $@.tmp + xcrun swiftc -O -target "$$(uname -m)-apple-macosx13.0" -framework AppKit $^ -o $@.tmp mv -f $@.tmp $@ # Visual preview only; does not change caffeine state. preview-glass: native - $(NATIVE_BINARY) enabled "Caffeine Enabled" 4 + native/.build/glass-toast enabled "Caffeine Enabled" 4 test: + hs -c "dofile(hs.configdir .. '/tests/test_chooser.lua').run()" + hs -c "dofile(hs.configdir .. '/tests/test_launcher.lua').run()" hs -c "dofile(hs.configdir .. '/tests/test_glass_toast.lua').run()" hs -c "dofile(hs.configdir .. '/tests/test_basic_alerts.lua').run()" hs -c "dofile(hs.configdir .. '/tests/test_stacking.lua').run()" diff --git a/hammerspoon/.hammerspoon/init.lua b/hammerspoon/.hammerspoon/init.lua index 6b7db84..e7f0ca4 100644 --- a/hammerspoon/.hammerspoon/init.lua +++ b/hammerspoon/.hammerspoon/init.lua @@ -72,6 +72,15 @@ wm.config.layouts = { wm:init() +-- Preserve window-state persistence while closing the native chooser on reload. +local save_window_state = hs.shutdownCallback +hs.shutdownCallback = function() + require("modules.chooser").shutdown() + if save_window_state then + save_window_state() + end +end + hs.hotkey.bind({ "cmd", "shift" }, "b", function() local applescript_toggle_menubar = [[ tell application "System Events" diff --git a/hammerspoon/.hammerspoon/modules/chooser.lua b/hammerspoon/.hammerspoon/modules/chooser.lua new file mode 100644 index 0000000..dd9847b --- /dev/null +++ b/hammerspoon/.hammerspoon/modules/chooser.lua @@ -0,0 +1,365 @@ +-- Native, glass-backed subset of hs.chooser. Never replaces the global hs.chooser. +local M = {} +local Chooser = {} +Chooser.__index = Chooser +local instances = setmetatable({}, { __mode = "k" }) +local log = hs.logger.new("chooser", "warning") + +local function plain(value) + if value == nil then + return "" + end + if type(value) == "userdata" and value.getString then + return value:getString() + end + return tostring(value) +end + +function M.new(completion) + local self = setmetatable({ + _completion = completion, + _query = "", + _placeholder = "Launcher", + _rows = 8, + _width = 42, + _maxWidth = 640, + _selected = 0, + _session = 0, + _revision = 0, + _input = 0, + _visible = false, + _display = {}, + _source = {}, + _images = setmetatable({}, { __mode = "k" }), + }, Chooser) + instances[self] = true + return self +end + +-- hs.task:setInput discards unwritten bytes. Keep exactly one snapshot in flight, +-- acknowledge it from Swift, and coalesce pending updates to the latest full state. +function Chooser:_flush() + if self._ready and not self._inflight and self._pending and self._task then + local state = self._pending + self._pending = nil + self._inflight = true + self._task:setInput(hs.json.encode(state) .. "\n") + end +end + +function Chooser:_update() + self._revision = self._revision + 1 + local choices = {} + for _, choice in ipairs(self._display) do + local image + if type(choice.image) == "userdata" then + image = self._images[choice.image] + if not image then + local ok, encoded = pcall(function() + return choice.image:setSize({ w = 32, h = 32 }):encodeAsURLString() + end) + if ok then + image = encoded + self._images[choice.image] = image + end + end + end + choices[#choices + 1] = + { text = plain(choice.text), subText = plain(choice.subText), image = image } + end + self._pending = { + session = self._session, + revision = self._revision, + input = self._input, + visible = self._visible, + query = self._query, + placeholder = self._placeholder, + rows = self._rows, + width = self._width, + maxWidth = self._maxWidth, + selected = self._selected, + choices = choices, + position = self._position, + } + self:_flush() +end + +function Chooser:_didHide() + local pending = self._hiding + self._hiding = nil + if not pending then + return + end + if self._hideCallback then + self._hideCallback() + end + if pending.complete and self._completion then + self._completion(pending.choice) + end +end + +function Chooser:_receive(event) + if event.event == "ready" then + self._ready = true + self:_flush() + return + elseif event.event == "ack" then + self._inflight = false + self:_flush() + return + end + if event.session ~= self._session then + return + end + if event.event == "hidden" and not self._visible then + self:_didHide() + elseif event.event == "shown" and self._visible then + if self._showCallback then + self._showCallback() + end + elseif self._visible then + if event.event == "query" then + self:_setQuery(event.query, event.input) + elseif event.event == "cancel" then + self:cancel() + elseif event.revision == self._revision then + if event.event == "selection" then + self._selected = event.row + elseif event.event == "select" and event.query == self._query then + self:select(event.row) + end + end + end +end + +function Chooser:_start() + if self._task then + return true + end + local binary = hs.configdir .. "/native/.build/native-chooser" + if not hs.fs.attributes(binary) then + hs.showError("Build the native launcher first: make -C ~/.hammerspoon native") + return false + end + local buffer = "" + local task + task = hs.task.new(binary, function(code, _, stderr) + if self._task ~= task then + return + end + self._task, self._ready, self._inflight = nil, false, false + local was_visible = self._visible + self._visible = false + if code ~= 0 then + log.w("Native chooser exited: " .. tostring(stderr)) + end + if was_visible and not self._hiding then + self._hiding = { complete = true } + end + self:_didHide() + end, function(_, stdout, stderr) + if self._task ~= task then + return false + end + if stderr and stderr ~= "" then + log.w(stderr) + end + buffer = buffer .. (stdout or "") + while true do + local newline = buffer:find("\n", 1, true) + if not newline then + break + end + local line = buffer:sub(1, newline - 1) + buffer = buffer:sub(newline + 1) + local ok, event = pcall(hs.json.decode, line) + if ok and type(event) == "table" then + -- A plugin callback must not permanently disable hs.task streaming. + local delivered, err = xpcall(self._receive, debug.traceback, self, event) + if not delivered then + log.w(err) + end + else + log.w("Invalid native chooser event") + end + end + return true + end) + self._task = task + if not task or not task:start() then + self._task = nil + hs.showError( + "Could not start the native launcher; rebuild with make -C ~/.hammerspoon native" + ) + return false + end + return true +end + +function Chooser:choices(source) + self._source = source or {} + return self:refreshChoicesCallback() +end + +function Chooser:refreshChoicesCallback() + local choices = self._source + if type(choices) == "function" then + local ok, result = xpcall(choices, debug.traceback) + if not ok then + log.w(result) + end + choices = ok and result or {} + end + self._display = {} + for _, choice in ipairs(choices or {}) do + local text = plain(choice.text) + if self._searchSubText then + text = text .. " " .. plain(choice.subText) + end + if type(self._source) == "function" or text:lower():find(self._query:lower(), 1, true) then + self._display[#self._display + 1] = choice + end + end + self._selected = #self._display > 0 and 1 or 0 + self:_update() + return self +end + +function Chooser:_setQuery(query, input) + self._query, self._input = query, input + if type(self._source) == "function" and self._queryChangedCallback then + -- Let the callback debounce dynamic matching; stale results cannot be selected. + self._display, self._selected = {}, 0 + self:_update() + else + self:refreshChoicesCallback() + end + if self._queryChangedCallback then + self._queryChangedCallback(query) + end + return self +end + +function Chooser:query(...) + if select("#", ...) == 0 then + return self._query + end + return self:_setQuery((...) or "", self._input + 1) +end + +function Chooser:show(position) + if self._visible then + return self + end + if self._deleted or not self:_start() then + return nil + end + self._session = self._session + 1 + self._input = 0 + self._visible = true + self._hiding = nil + self._position = position + self:refreshChoicesCallback() + return self +end + +function Chooser:_finish(choice, complete) + if self._visible then + self._visible = false + self._hiding = { choice = choice, complete = complete } + self:_update() + end + return self +end + +function Chooser:hide() + return self:_finish(nil, false) +end + +function Chooser:cancel() + return self:_finish(nil, true) +end + +function Chooser:isVisible() + return self._visible +end + +function Chooser:select(row) + local choice = self._display[row or self._selected] + if choice and choice.valid == false then + if self._invalidCallback then + self._invalidCallback(choice) + end + return self + end + return self:_finish(choice, true) +end + +function Chooser:selectedRow(row) + if row == nil then + return self._selected + end + self._selected = self._display[row] and row or 0 + self:_update() + return self +end + +function Chooser:selectedRowContents(row) + return self._display[row or self._selected] or {} +end + +for _, name in ipairs({ "queryChangedCallback", "showCallback", "hideCallback", "invalidCallback" }) do + Chooser[name] = function(self, fn) + self["_" .. name] = fn + return self + end +end + +for _, name in ipairs({ "rows", "width", "maxWidth", "placeholderText", "searchSubText" }) do + local field = name == "placeholderText" and "_placeholder" or "_" .. name + Chooser[name] = function(self, value) + if value == nil then + return self[field] + end + if name == "maxWidth" then + assert( + type(value) == "number" and value >= 360 and value < math.huge, + "expected a finite width of at least 360 points" + ) + elseif name == "rows" or name == "width" then + assert( + type(value) == "number" and value >= 1 and value <= 100, + "expected a number in 1–100" + ) + value = name == "rows" and math.floor(value) or value + end + self[field] = value + if name == "searchSubText" then + self:refreshChoicesCallback() + else + self:_update() + end + return self + end +end + +function Chooser:delete() + self._deleted = true + self._visible = false + self._hiding = nil + local task = self._task + self._task = nil + if task then + task:closeInput() + task:terminate() + end + instances[self] = nil +end + +function M.shutdown() + for instance in pairs(instances) do + instance:delete() + end +end + +return M diff --git a/hammerspoon/.hammerspoon/modules/plugins.lua b/hammerspoon/.hammerspoon/modules/plugins.lua index 5fbbb9f..9855e63 100644 --- a/hammerspoon/.hammerspoon/modules/plugins.lua +++ b/hammerspoon/.hammerspoon/modules/plugins.lua @@ -29,20 +29,17 @@ local app_search_paths = { "/usr/local/Cellar", } -spoon.SpoonInstall:andUse("Seal", { - fn = function(s) - s:loadPlugins({ "apps", "calc", "screencapture", "useractions" }) - s.plugins.useractions.actions = { - ["Translate w/ Google Translate"] = { - icon = "favicon", - keyword = "tr", - url = "https://translate.google.com/?sl=en&tl=fr&text=${query}&op=translate", - }, - } - s.plugins.apps.appSearchPaths = app_search_paths - s.plugins.apps:restart() - return s:refreshAllCommands() - end, - hotkeys = { show = { { "cmd" }, "p" } }, - start = true, -}) +-- Locally maintained Spoons are declarative source, not SpoonInstall downloads. +package.path = hs.configdir .. "/vendored/?.spoon/init.lua;" .. package.path +local launcher = hs.loadSpoon("Launcher") +launcher:loadPlugins({ "apps", "calc", "screencapture", "useractions" }) +launcher.plugins.useractions.actions = { + ["Translate w/ Google Translate"] = { + icon = "favicon", + keyword = "tr", + url = "https://translate.google.com/?sl=en&tl=fr&text=${query}&op=translate", + }, +} +launcher.plugins.apps.appSearchPaths = app_search_paths +launcher.plugins.apps:restart() +launcher:refreshAllCommands():bindHotkeys({ show = { { "cmd" }, "p" } }):start() diff --git a/hammerspoon/.hammerspoon/native/GlassBackground.swift b/hammerspoon/.hammerspoon/native/GlassBackground.swift new file mode 100644 index 0000000..be3d96b --- /dev/null +++ b/hammerspoon/.hammerspoon/native/GlassBackground.swift @@ -0,0 +1,29 @@ +import AppKit + +// Shared by the non-activating toast and the focusable launcher. +func glassBackground(for content: NSView, cornerRadius: CGFloat) -> NSView { + if #available(macOS 26.0, *) { + let glass = NSGlassEffectView(frame: content.frame) + glass.style = .regular + glass.cornerRadius = cornerRadius + glass.contentView = content + return glass + } + + let blur = NSVisualEffectView(frame: content.frame) + blur.material = .hudWindow + blur.blendingMode = .behindWindow + blur.state = .active + blur.wantsLayer = true + blur.layer?.cornerRadius = cornerRadius + blur.layer?.masksToBounds = true + blur.addSubview(content) + content.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: blur.leadingAnchor), + content.trailingAnchor.constraint(equalTo: blur.trailingAnchor), + content.topAnchor.constraint(equalTo: blur.topAnchor), + content.bottomAnchor.constraint(equalTo: blur.bottomAnchor), + ]) + return blur +} diff --git a/hammerspoon/.hammerspoon/native/GlassToast.swift b/hammerspoon/.hammerspoon/native/GlassToast.swift index 1897102..d43246a 100644 --- a/hammerspoon/.hammerspoon/native/GlassToast.swift +++ b/hammerspoon/.hammerspoon/native/GlassToast.swift @@ -76,23 +76,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panel.isReleasedWhenClosed = false let content = NSView(frame: NSRect(origin: .zero, size: size)) - if #available(macOS 26.0, *) { - let glass = NSGlassEffectView(frame: content.bounds) - glass.style = .regular - glass.cornerRadius = 22 - glass.contentView = content - panel.contentView = glass - } else { - let blur = NSVisualEffectView(frame: content.bounds) - blur.material = .hudWindow - blur.blendingMode = .behindWindow - blur.state = .active - blur.wantsLayer = true - blur.layer?.cornerRadius = 22 - blur.layer?.masksToBounds = true - blur.addSubview(content) - panel.contentView = blur - } + panel.contentView = glassBackground(for: content, cornerRadius: 22) let icon = NSImageView() icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? @@ -143,8 +127,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -let app = NSApplication.shared -app.setActivationPolicy(.accessory) -let delegate = AppDelegate() -app.delegate = delegate -app.run() +@main +private enum GlassToastMain { + static func main() { + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let delegate = AppDelegate() + app.delegate = delegate + withExtendedLifetime(delegate) { app.run() } + } +} diff --git a/hammerspoon/.hammerspoon/native/NativeChooser.swift b/hammerspoon/.hammerspoon/native/NativeChooser.swift new file mode 100644 index 0000000..07fda71 --- /dev/null +++ b/hammerspoon/.hammerspoon/native/NativeChooser.swift @@ -0,0 +1,386 @@ +import AppKit + +private struct Choice: Decodable { + let text: String + let subText: String + let image: String? +} + +private struct Position: Decodable { + let x: Double + let y: Double +} + +private struct Snapshot: Decodable { + let session: Int + let revision: Int + let input: Int + let visible: Bool + let query: String + let placeholder: String + let rows: Int + let width: Double + let maxWidth: Double? + let selected: Int + let choices: [Choice] + let position: Position? +} + +private final class ChooserPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +private final class ChoiceRow: NSTableRowView { + override func drawBackground(in dirtyRect: NSRect) { + // Leave unselected rows transparent instead of drawing a table material. + } + + override func drawSelection(in dirtyRect: NSRect) { + guard selectionHighlightStyle != .none else { return } + NSColor.labelColor.withAlphaComponent(0.08).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 4, dy: 3), xRadius: 10, yRadius: 10).fill() + } +} + +// stdout is reserved for newline-delimited JSON; diagnostics belong on stderr. +private func emit(_ event: String, _ values: [String: Any] = [:]) { + var message = values + message["event"] = event + guard let data = try? JSONSerialization.data(withJSONObject: message) else { return } + FileHandle.standardOutput.write(data + Data([10])) +} + +private final class Chooser: NSObject, NSApplicationDelegate, NSWindowDelegate, + NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate +{ + private let panel = ChooserPanel( + contentRect: .zero, styleMask: [.borderless], backing: .buffered, defer: false) + private let shadowInset: CGFloat = 24 + private let search = NSSearchField() + private let table = NSTableView() + private let scroll = NSScrollView() + private let empty = NSTextField(labelWithString: "Search apps, calculations, and actions") + private var snapshot: Snapshot? + private var choices: [Choice] = [] + private var previousApp: NSRunningApplication? + private var applying = false + private var inputVersion = 0 + private var images: [String: NSImage] = [:] + + func applicationDidFinishLaunching(_ notification: Notification) { + // A CLI-hosted AppKit app has no nib-provided Edit menu. Supply the standard + // responder-chain commands so Cmd+A/C/V/X/Z work in the search field. + let menu = NSMenu() + let application = NSMenuItem() + application.submenu = NSMenu(title: "Launcher") + application.submenu?.addItem( + withTitle: "Quit Launcher", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q" + ) + menu.addItem(application) + let edit = NSMenuItem() + edit.submenu = NSMenu(title: "Edit") + for (title, action, key) in [ + ("Undo", Selector(("undo:")), "z"), + ("Redo", Selector(("redo:")), "Z"), + ("Cut", #selector(NSText.cut(_:)), "x"), + ("Copy", #selector(NSText.copy(_:)), "c"), + ("Paste", #selector(NSText.paste(_:)), "v"), + ("Select All", #selector(NSText.selectAll(_:)), "a"), + ] { + edit.submenu?.addItem(withTitle: title, action: action, keyEquivalent: key) + } + menu.addItem(edit) + NSApp.mainMenu = menu + panel.delegate = self + panel.isReleasedWhenClosed = false + panel.isOpaque = false + panel.backgroundColor = .clear + // The glass surface supplies its own edge treatment; an NSWindow shadow + // adds a dark rectangular outline around the rounded, transparent panel. + panel.hasShadow = false + panel.level = .floating + panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary, .ignoresCycle] + + let content = NSView() + let glass = glassBackground(for: content, cornerRadius: 24) + // Glass draws its own soft shading outside its bounds. Keep that shading + // inside a transparent window margin instead of cropping it to a rectangle. + let backing = NSView() + backing.wantsLayer = true + backing.layer?.backgroundColor = NSColor.clear.cgColor + glass.translatesAutoresizingMaskIntoConstraints = false + backing.addSubview(glass) + NSLayoutConstraint.activate([ + glass.leadingAnchor.constraint(equalTo: backing.leadingAnchor, constant: shadowInset), + glass.trailingAnchor.constraint(equalTo: backing.trailingAnchor, constant: -shadowInset), + glass.topAnchor.constraint(equalTo: backing.topAnchor, constant: shadowInset), + glass.bottomAnchor.constraint(equalTo: backing.bottomAnchor, constant: -shadowInset), + ]) + panel.contentView = backing + + let searchIcon = NSImageView() + searchIcon.image = NSImage( + systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 20, weight: .medium)) + searchIcon.contentTintColor = .secondaryLabelColor + // A separate icon avoids NSSearchFieldCell's overlapping bezel-less text rect. + (search.cell as? NSSearchFieldCell)?.searchButtonCell = nil + search.delegate = self + search.font = .systemFont(ofSize: 23) + search.isBezeled = false + search.drawsBackground = false + search.focusRingType = .none + search.sendsSearchStringImmediately = true + search.sendsWholeSearchString = false + + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("choice"))) + table.headerView = nil + table.style = .plain + table.usesAlternatingRowBackgroundColors = false + table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + table.backgroundColor = .clear + table.rowHeight = 58 + table.intercellSpacing = .zero + table.dataSource = self + table.delegate = self + table.target = self + table.doubleAction = #selector(accept) + table.focusRingType = .none + scroll.documentView = table + scroll.drawsBackground = false + scroll.contentView.drawsBackground = false + scroll.backgroundColor = .clear + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + empty.textColor = .secondaryLabelColor + empty.font = .systemFont(ofSize: 13) + let separator = NSBox() + separator.boxType = .separator + for view in [searchIcon, search, separator, scroll, empty] { + view.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(view) + } + NSLayoutConstraint.activate([ + searchIcon.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 22), + searchIcon.centerYAnchor.constraint(equalTo: search.centerYAnchor), + searchIcon.widthAnchor.constraint(equalToConstant: 22), + searchIcon.heightAnchor.constraint(equalToConstant: 22), + search.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 12), + search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -22), + search.topAnchor.constraint(equalTo: content.topAnchor, constant: 18), + search.heightAnchor.constraint(equalToConstant: 34), + separator.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 16), + separator.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), + separator.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), + scroll.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 8), + scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 10), + scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -10), + scroll.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12), + empty.centerXAnchor.constraint(equalTo: scroll.centerXAnchor), + empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor), + ]) + + // EOF shuts down the UI when Hammerspoon exits or reloads its Lua state. + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + while let line = readLine() { + guard let data = line.data(using: .utf8), + let state = try? JSONDecoder().decode(Snapshot.self, from: data) + else { + FileHandle.standardError.write(Data("native-chooser: invalid snapshot\n".utf8)) + continue + } + DispatchQueue.main.async { self?.apply(state) } + } + DispatchQueue.main.async { NSApp.terminate(nil) } + } + emit("ready") + } + + private func event(_ name: String, _ extra: [String: Any] = [:]) { + guard let state = snapshot else { return } + var values = extra + values["session"] = state.session + values["revision"] = state.revision + values["input"] = inputVersion + emit(name, values) + } + + private func apply(_ state: Snapshot) { + applying = true + defer { + applying = false + emit("ack") + } + if let current = snapshot { + // A snapshot computed before the latest keystroke must not overwrite it. + if state.session < current.session { return } + if state.session == current.session && state.visible && state.input < inputVersion { return } + } + inputVersion = state.input + let opening = state.visible && (!panel.isVisible || snapshot?.session != state.session) + snapshot = state + guard state.visible else { + hide() + event("hidden") + return + } + choices = state.choices + if images.count > 256 { images.removeAll() } + search.placeholderString = state.placeholder + if search.stringValue != state.query { search.stringValue = state.query } + table.reloadData() + let selected = state.selected - 1 + if choices.indices.contains(selected) { + table.selectRowIndexes(IndexSet(integer: selected), byExtendingSelection: false) + table.scrollRowToVisible(selected) + } else { + table.deselectAll(nil) + } + empty.stringValue = + state.query.isEmpty ? "Search apps, calculations, and actions" : "No results" + empty.isHidden = !choices.isEmpty + guard let screen = (opening ? NSScreen.main : panel.screen) ?? NSScreen.main else { return } + let frame = screen.visibleFrame + let width = min( + frame.width - 32, state.maxWidth ?? 640, max(360, frame.width * state.width / 100)) + let height = min( + frame.height - 64, 90 + CGFloat(max(1, min(state.rows, choices.count))) * table.rowHeight) + // Keep the top edge stable as results change, with room below the search field. + let top = + state.position.map { (NSScreen.screens.first?.frame.maxY ?? 0) - $0.y } + ?? (frame.maxY - frame.height * 0.22) + let x = state.position?.x ?? (frame.midX - width / 2) + let surfaceFrame = NSRect(x: x, y: top - height, width: width, height: height) + panel.setFrame(surfaceFrame.insetBy(dx: -shadowInset, dy: -shadowInset), display: true) + if opening { + if !panel.isVisible { previousApp = NSWorkspace.shared.frontmostApplication } + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + panel.makeFirstResponder(search) + search.currentEditor()?.selectedRange = NSRange( + location: search.stringValue.utf16.count, length: 0) + event("shown") + } + } + + private func hide(restoreFocus: Bool = true) { + guard panel.isVisible else { return } + let wasApplying = applying + applying = true + panel.orderOut(nil) + applying = wasApplying + if restoreFocus && NSApp.isActive { previousApp?.activate(options: []) } + previousApp = nil + } + + func windowDidResignKey(_ notification: Notification) { + if !applying && panel.isVisible { + event("cancel") + // A click into another app should keep focus there, not restore the old app. + hide(restoreFocus: false) + } + } + + func controlTextDidChange(_ notification: Notification) { + guard !applying else { return } + // Results from the previous query must not be selectable during Lua's debounce. + choices = [] + inputVersion += 1 + table.reloadData() + event("query", ["query": search.stringValue]) + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy command: Selector) -> Bool { + switch command { + case #selector(NSResponder.moveDown(_:)): + moveSelection(1) + case #selector(NSResponder.moveUp(_:)): + moveSelection(-1) + case #selector(NSResponder.insertNewline(_:)): + accept() + case #selector(NSResponder.cancelOperation(_:)): + event("cancel") + hide() + default: + return false + } + return true + } + + private func moveSelection(_ offset: Int) { + guard !choices.isEmpty else { return } + let row = min(choices.count - 1, max(0, table.selectedRow + offset)) + table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + table.scrollRowToVisible(row) + } + + @objc private func accept() { + guard choices.indices.contains(table.selectedRow) else { return } + event("select", ["row": table.selectedRow + 1, "query": search.stringValue]) + } + + func numberOfRows(in tableView: NSTableView) -> Int { choices.count } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + ChoiceRow() + } + + func tableViewSelectionDidChange(_ notification: Notification) { + if !applying { event("selection", ["row": table.selectedRow + 1]) } + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + let choice = choices[row] + let cell = NSTableCellView() + let icon = NSImageView() + if let encoded = choice.image { + if let cached = images[encoded] { + icon.image = cached + } else if let comma = encoded.firstIndex(of: ","), encoded.hasPrefix("data:image/"), + let data = Data(base64Encoded: String(encoded[encoded.index(after: comma)...])), + let image = NSImage(data: data) + { + images[encoded] = image + icon.image = image + } + } + let title = NSTextField(labelWithString: choice.text) + title.font = .systemFont(ofSize: 15, weight: .medium) + title.lineBreakMode = .byTruncatingTail + let detail = NSTextField(labelWithString: choice.subText) + detail.font = .systemFont(ofSize: 12) + detail.textColor = .secondaryLabelColor + detail.lineBreakMode = .byTruncatingMiddle + let labels = NSStackView(views: choice.subText.isEmpty ? [title] : [title, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 3 + for view in [icon, labels] { + view.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(view) + } + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 12), + icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 32), + icon.heightAnchor.constraint(equalToConstant: 32), + labels.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12), + labels.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -12), + labels.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} + +@main +private enum NativeChooserMain { + static func main() { + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let delegate = Chooser() + app.delegate = delegate + withExtendedLifetime(delegate) { app.run() } + } +} diff --git a/hammerspoon/.hammerspoon/native/README.md b/hammerspoon/.hammerspoon/native/README.md index 9d44c63..e2f661d 100644 --- a/hammerspoon/.hammerspoon/native/README.md +++ b/hammerspoon/.hammerspoon/native/README.md @@ -1,6 +1,9 @@ # Native glass toast prototype A centered, click-through, non-activating AppKit panel for caffeine and reload feedback. +`make native` also builds the interactive Launcher chooser; see +[`Launcher.spoon`](../vendored/Launcher.spoon/README.md) for that UI and its API. +Both helpers use `GlassBackground.swift` for the same native material. Uses `NSGlassEffectView` on macOS 26+ and `NSVisualEffectView` (`hudWindow`) on macOS 13–25. No notification permission, Dock icon, or Notification Center history. Accessibility settings such as Reduce Transparency can change the effect. diff --git a/hammerspoon/.hammerspoon/tests/test_chooser.lua b/hammerspoon/.hammerspoon/tests/test_chooser.lua new file mode 100644 index 0000000..f833d6f --- /dev/null +++ b/hammerspoon/.hammerspoon/tests/test_chooser.lua @@ -0,0 +1,222 @@ +-- Pure adapter tests: no UI, processes, Spotlight, or application launches. +local test = {} + +function test.run() + local tasks, errors, completions = {}, {}, {} + local exists, starts = true, true + local fake_hs = { + configdir = "/test", + json = hs.json, + fs = { + attributes = function() + return exists and {} or nil + end, + }, + logger = { + new = function() + return { w = function() end } + end, + }, + showError = function(message) + errors[#errors + 1] = message + end, + task = { + new = function(path, callback, stream) + local task = { path = path, callback = callback, stream = stream, writes = {} } + function task:start() + return starts and self or false + end + function task:setInput(data) + self.writes[#self.writes + 1] = data + end + function task:closeInput() + self.closed = true + end + function task:terminate() + self.terminated = true + end + tasks[#tasks + 1] = task + return task + end, + }, + } + local env = setmetatable({ hs = fake_hs }, { __index = _G }) + local module = assert(loadfile(hs.configdir .. "/modules/chooser.lua", "t", env))() + local chooser = module.new(function(choice) + completions[#completions + 1] = { choice = choice } + end) + local custom = + { text = "Firefox", subText = "Browser", image = "favicon", custom = { id = 42 } } + local invalid = { text = "Find", valid = false } + local shown, hidden, invalids = 0, 0, 0 + chooser + :showCallback(function() + shown = shown + 1 + end) + :hideCallback(function() + hidden = hidden + 1 + end) + :invalidCallback(function() + invalids = invalids + 1 + end) + :choices({ custom, invalid }) + assert(chooser:query() == "" and chooser:rows() == 8, "default properties") + assert(chooser:query(nil) == chooser and chooser:query() == "", "explicit nil clears query") + assert(chooser:show() == chooser and chooser:isVisible(), "native chooser opens") + local task = tasks[1] + assert(task.path == "/test/native/.build/native-chooser", "native binary path") + local function emit(event, split) + event.session = event.session or chooser._session + event.revision = event.revision or chooser._revision + local line = hs.json.encode(event) .. "\n" + if split then + task.stream(task, line:sub(1, 5), "") + task.stream(task, line:sub(6), "") + else + task.stream(task, line, "") + end + end + local function drain() + while chooser._inflight do + emit({ event = "ack" }) + end + end + local function latest() + return hs.json.decode(task.writes[#task.writes]) + end + chooser:query("fi"):rows(6) + assert(#task.writes == 0, "waits for native readiness") + emit({ event = "ready" }, true) + assert(#task.writes == 1 and latest().query == "fi", "split ready event flushes latest state") + assert(latest().choices[1].image == nil, "ignore unresolved plugin icon placeholders") + assert(latest().maxWidth == 640, "compact default maximum width") + chooser:query("fire"):width(45):maxWidth(600) + assert(#task.writes == 1, "never overwrite unacknowledged task input") + emit({ event = "ack" }) + assert( + #task.writes == 2 + and latest().query == "fire" + and latest().width == 45 + and latest().maxWidth == 600, + "coalesce snapshots including maximum width" + ) + drain() + emit({ event = "shown" }) + assert(shown == 1, "shown callback") + chooser:showCallback(function() + error("test callback failure") + end) + assert( + pcall(function() + emit({ event = "shown" }) + end), + "callback error must not break transport" + ) + chooser:showCallback(nil) + assert(chooser:selectedRowContents() == custom, "retain original choice including metadata") + emit({ event = "select", revision = chooser._revision - 1, row = 1, query = "fire" }) + assert(chooser:isVisible() and #completions == 0, "reject stale selection revisions") + emit({ event = "query", input = 3, query = "fi" }) + drain() + assert(chooser:query() == "fi" and latest().input == 3, "native query changes reach Lua") + chooser:selectedRow(2) + drain() + emit({ event = "select", row = 2, query = "fi" }) + assert(invalids == 1 and chooser:isVisible(), "invalid choice stays open") + chooser:select(1) + assert(#completions == 0, "wait for native hide before executing action") + drain() + emit({ event = "hidden" }) + assert( + #completions == 1 and completions[1].choice == custom, + "dispatch original choice after hide" + ) + assert(hidden == 1 and not chooser:isVisible(), "hide callback") + + local old_session = chooser._session + chooser:show() + drain() + emit({ event = "cancel", session = old_session }) + assert(chooser:isVisible(), "ignore events from a previous show session") + chooser:hide() + drain() + emit({ event = "hidden" }) + assert(#completions == 1, "programmatic hide does not complete") + chooser:show() + drain() + emit({ event = "cancel" }) + drain() + emit({ event = "hidden" }) + assert(#completions == 2 and completions[2].choice == nil, "cancel completes with nil") + + chooser:query("browser"):searchSubText(true) + assert(chooser:selectedRowContents() == custom, "static subtext filtering") + chooser + :choices(function() + return { custom } + end) + :query("anything") + assert(chooser:selectedRowContents() == custom, "callback choices are not filtered twice") + local query, refreshes = nil, 0 + chooser:choices(function() + refreshes = refreshes + 1 + return { custom } + end) + local before = refreshes + chooser:rows(5):width(40):maxWidth(580):placeholderText("Search") + assert(refreshes == before, "display-only changes must not rerun matching") + chooser + :queryChangedCallback(function(value) + query = value + chooser:refreshChoicesCallback() + end) + :query("hello") + assert(query == "hello", "programmatic query callback") + assert(refreshes == before + 1, "query callback should match once, not twice") + assert(chooser:selectedRowContents() == custom, "callback supplies current query results") + chooser:show() + drain() + before = refreshes + emit({ event = "query", input = 1, query = "native input" }) + drain() + assert( + query == "native input" and refreshes == before + 1, + "native queries use the same matching path" + ) + + local observed + local static = module.new():choices({ custom, invalid }):queryChangedCallback(function(value) + observed = value + end) + static:query("find") + assert( + observed == "find" and static:selectedRowContents() == invalid, + "static filtering still works with an observer" + ) + assert(not pcall(function() + chooser:rows(0) + end), "reject invalid row count") + assert(not pcall(function() + chooser:maxWidth(0) + end), "reject invalid maximum width") + chooser:show() + task.callback(1, "", "crash") + assert(not chooser:isVisible() and #completions == 3, "helper failure cancels visible chooser") + chooser:show() + assert(#tasks == 2, "restart after helper failure") + module.shutdown() + assert( + tasks[2].closed and tasks[2].terminated and not chooser:isVisible(), + "shutdown closes task" + ) + assert(chooser:show() == nil, "deleted chooser cannot restart") + exists = false + assert(module.new():show() == nil and #errors == 1, "missing helper reports build instructions") + exists, starts = true, false + assert(module.new():show() == nil, "start failure does not report visible") + print( + "PASS: native chooser transport, filtering, selection, callbacks, sessions, failure, and cleanup" + ) +end + +return test diff --git a/hammerspoon/.hammerspoon/tests/test_launcher.lua b/hammerspoon/.hammerspoon/tests/test_launcher.lua new file mode 100644 index 0000000..3be7af2 --- /dev/null +++ b/hammerspoon/.hammerspoon/tests/test_launcher.lua @@ -0,0 +1,129 @@ +-- Launcher engine tests without Spotlight, windows, or action execution. +local test = {} + +function test.run() + local query, visible, completion = "", true, nil + local native = {} + function native:choices(fn) + self.choicesFn = fn + return self + end + function native:queryChangedCallback(fn) + self.changed = fn + return self + end + function native:invalidCallback(fn) + self.invalid = fn + return self + end + function native:query(value) + if value ~= nil then + query = value + return self + end + return query + end + function native:isVisible() + return visible + end + function native:refreshChoicesCallback() + self.refreshed = true + end + function native:show() + visible = true + end + function native:hide() + visible = false + end + local env = setmetatable({ + require = function(name) + assert(name == "modules.chooser") + return { + new = function(fn) + completion = fn + return native + end, + } + end, + hs = { + configdir = "/test", + spoons = { + scriptPath = function() + return "/test/Launcher.spoon/" + end, + }, + timer = { + doAfter = function(_, fn) + fn() + return { stop = function() end } + end, + }, + }, + }, { __index = _G }) + local launcher = + assert(loadfile(hs.configdir .. "/vendored/Launcher.spoon/init.lua", "t", env))() + local selected, argument + local choice = { text = "Test app", plugin = "test", payload = { id = 123 } } + local plugin = { + __name = "test", + commands = function() + return { + reveal = { + cmd = "reveal", + name = "Reveal", + description = "Find a file", + plugin = "test", + fn = function(value) + argument = value + return { choice } + end, + }, + } + end, + bare = function() + return function() + return {} + end + end, + completionCallback = function(value) + selected = value + end, + } + launcher.plugins.test = plugin + launcher:refreshAllCommands():loadPlugins({ "test" }) + assert(launcher.name == "Launcher" and launcher.chooser == native, "use native adapter") + query = "reveal file name" + local result = native.choicesFn() + assert(argument == "file name" and result[1] == choice, "dispatch command with arguments") + query = "rev" + result = native.choicesFn() + assert( + #result == 1 and result[1].cmd == "reveal" and result[1].valid == false, + "command prompt stays open" + ) + native.invalid(result[1]) + assert(query == "reveal ", "command prompt populates query") + query = "reveal" + native.choicesFn() + assert(argument == ".*", "preserve plugin empty-argument sentinel") + query = "%" + assert(#native.choicesFn() == 0, "search queries are literal, not Lua patterns") + query = " " + assert(#native.choicesFn() == 0, "blank query") + completion(choice) + assert(selected == choice and selected.payload.id == 123, "dispatch original choice to plugin") + native.changed() + assert(native.refreshed, "debounced query refresh") + plugin.commands = function() + return {} + end + launcher:refreshAllCommands() + assert(next(launcher.commands) == nil, "remove commands no longer supplied by plugin") + launcher:toggle() + assert(not visible, "toggle hides") + launcher:show("new query") + assert(visible and query == "new query", "show can prepopulate query") + print("PASS: Launcher engine, command prompts, plugin dispatch, literal queries, and refresh") +end + +return test diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/LICENSE b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/LICENSE new file mode 100644 index 0000000..d40a68d --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Chris Jones and Hammerspoon/Spoons contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/README.md b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/README.md new file mode 100644 index 0000000..ef6afaa --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/README.md @@ -0,0 +1,87 @@ +# Launcher.spoon + +A local fork of Seal's plugin engine using `require("modules.chooser")` for a +native glass UI. The normal shortcut remains **Cmd+P**. Lua retains matching, +plugin actions, original choice objects, Spotlight indexing, and user settings; +Swift owns text input, result rendering, keyboard navigation, and focus. + +## Upstream and local changes + +Vendored from [Hammerspoon/Spoons, Source/Seal.spoon](https://github.com/Hammerspoon/Spoons/tree/2ecaa0523fcf4f90521db9ed5e41749c366dd6b9/Source/Seal.spoon) +at revision `2ecaa0523fcf4f90521db9ed5e41749c366dd6b9`, licensed MIT by Chris +Jones and contributors. The four configured plugins are included: `apps`, +`calc`, `screencapture`, and `useractions`. Plugin files retain upstream names, +`plugin.seal`, and `seal_*` settings keys so existing bookmarks/actions survive. +Additional Seal plugins can be supplied via `launcher_plugins` or `seal_plugins`. + +The core is adapted to use the native chooser, avoid global scratch variables, +keep command prompts open, remove obsolete dynamic commands, and sort results. +The vendored apps/useractions plugins use literal search matching so punctuation +is not treated as a Lua pattern; apps' icon/bundle variables are now local. +Other plugin behavior is intentionally retained. This is not a general plugin +sandbox: plugins run Lua in Hammerspoon with the same access as before. + +This directory is tracked declarative source under `vendored`, distinct +from ignored, downloaded `Spoons`. `hs.loadSpoon` resolves it via `package.path`; +SpoonInstall does not download, update, or overwrite Launcher. + +## Build and use + +Requires Apple Command Line Tools with a macOS 26+ SDK. Stow the `hammerspoon` +package, run `make -C ~/.hammerspoon native`, and reload Hammerspoon. +Use Cmd+P, type a query, then Up/Down and Return to select. Double-click also +selects; Escape or clicking another app cancels. Standard Cmd+A/C/V/X/Z editing +shortcuts are supported. The panel hides and requests previous-app focus before +returning the selected result to Lua. Removing the binary disables the +launcher and reports build instructions; there is **no hs.chooser fallback**. + +## Native chooser boundary + +`modules.chooser.new(completion)` implements the subset used by Launcher: + +- `choices(table | function | nil)`, `refreshChoicesCallback()` +- `query([string | nil])`, `queryChangedCallback(fn)` +- `show([position])`, `hide()`, `cancel()`, `isVisible()`, `delete()` +- `select([row])`, `selectedRow([row])`, `selectedRowContents([row])` +- `showCallback(fn)`, `hideCallback(fn)`, `invalidCallback(fn)` +- `rows([count])`, `width([screenPercent])`, `placeholderText([text])`, + `searchSubText([boolean])` +- `maxWidth([points])`: native extension, defaults to 640 points so the launcher + stays compact on large displays; `width` sets a percentage within that cap + +Rows are one-based. Choice metadata stays in Lua; only display text, subtext, +resized PNG icons, and row selection cross the process boundary. `valid=false` +invokes the invalid callback without hiding. Static choices are filtered by +literal case-insensitive text; callback choices are already matched by Lua. +`hide` does not invoke completion, while `cancel` completes with nil. Actions +and hide callbacks wait for the native window to actually hide. + +This is **not full hs.chooser parity**. Rich styled text is flattened to plain +text. Toolbars, right-click menus, custom foreground/background colors, global +callbacks, and default-for-query behavior are not implemented. Refresh is eager, +not a replica of hs.chooser's cached callback behavior. Rows/width are bounded +to 1–100; maximum width must be finite and at least 360 points. Callbacks +returning choices retain original Lua objects. The global +`hs.chooser` is never patched. Native typing and programmatic queries share the +same update path: static choices filter immediately, while a dynamic source's +query callback controls debounced refresh. Display-only options update the UI +without rerunning plugin matching. + +The native process is persistent while Hammerspoon runs, communicating over +newline-delimited JSON on stdin/stdout. Full snapshots are acknowledged one at +a time and pending snapshots coalesce, since `hs.task:setInput` otherwise drops +unwritten bytes. Session/revision/input counters reject stale selections and +prevent old results from replacing newer typing. EOF and the chained shutdown +callback close the helper on reload without losing window-state persistence. + +## Validation + +```sh +hs -c 'dofile(hs.configdir .. "/tests/test_chooser.lua").run()' +hs -c 'dofile(hs.configdir .. "/tests/test_launcher.lua").run()' +xcrun swift-format lint --strict ~/.hammerspoon/native/NativeChooser.swift +``` + +Adapter and engine tests use mocked processes/UI and execute no plugin actions. +Native integration also needs a macOS visual/input test. The material fallback +on macOS 13–25 has not been visually tested on an older host. diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/init.lua b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/init.lua new file mode 100644 index 0000000..4e225c4 --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/init.lua @@ -0,0 +1,205 @@ +--- === Launcher === +--- Seal's plugin engine with a native glass-backed chooser. +--- Derived from Hammerspoon/Spoons Seal (MIT), by Chris Jones and contributors. +local chooser = require("modules.chooser") + +local obj = { + name = "Launcher", + version = "0.1", + author = "Chris Jones and contributors", + homepage = "https://github.com/Hammerspoon/Spoons", + license = "MIT - https://opensource.org/licenses/MIT", + plugins = {}, + commands = {}, + queryChangedTimerDuration = 0.02, + spoonPath = hs.spoons.scriptPath(), +} +obj.plugin_search_paths = { + hs.configdir .. "/launcher_plugins", + hs.configdir .. "/seal_plugins", + obj.spoonPath, +} + +function obj:refreshCommandsForPlugin(name) + local plugin = self.plugins[name] + for command, info in pairs(self.commands) do + if info.plugin == plugin.__name then + self.commands[command] = nil + end + end + for command, info in pairs(plugin.commands and plugin:commands() or {}) do + self.commands[command] = info + end + return self +end + +function obj:refreshAllCommands() + for name in pairs(self.plugins) do + self:refreshCommandsForPlugin(name) + end + return self +end + +function obj:loadPluginFromFile(name, path) + local chunk = loadfile(path) + if not chunk then + return nil + end + local plugin = chunk() + -- Preserve the upstream plugin contract and stored settings namespaces. + plugin.seal = self + plugin.launcher = self + self.plugins[name] = plugin + self:refreshCommandsForPlugin(name) + return self +end + +function obj:loadPlugins(names) + if not self.chooser then + self.chooser = chooser + .new(self.completionCallback) + :choices(self.choicesCallback) + :queryChangedCallback(self.queryChangedCallback) + :invalidCallback(function(choice) + if choice.type == "plugin_cmd" then + self.chooser:query(choice.cmd .. " ") + end + end) + end + for _, name in ipairs(names) do + if not self.plugins[name] then + for _, directory in ipairs(self.plugin_search_paths) do + if self:loadPluginFromFile(name, directory .. "/seal_" .. name .. ".lua") then + break + end + end + assert(self.plugins[name], "Launcher plugin not found: " .. name) + end + end + return self +end + +function obj:bindHotkeys(mapping) + for _, action in ipairs({ "show", "toggle" }) do + local field = "hotkey_" .. action + if self[field] then + self[field]:delete() + self[field] = nil + end + if mapping[action] then + self[field] = hs.hotkey.new(mapping[action][1], mapping[action][2], function() + self[action](self) + end) + end + end + return self +end + +function obj:start() + for _, field in ipairs({ "hotkey_show", "hotkey_toggle" }) do + if self[field] then + self[field]:enable() + end + end + return self +end + +function obj:stop() + if self.queryChangedTimer then + self.queryChangedTimer:stop() + end + if self.chooser then + self.chooser:hide() + end + for _, field in ipairs({ "hotkey_show", "hotkey_toggle" }) do + if self[field] then + self[field]:disable() + end + end + return self +end + +function obj:show(query) + if query ~= nil then + self.chooser:query(query) + end + self.chooser:show() + return self +end + +function obj:toggle(query) + if self.chooser:isVisible() then + self.chooser:hide() + else + self:show(query) + end + return self +end + +function obj.completionCallback(choice) + if not choice then + return + end + for _, plugin in pairs(obj.plugins) do + if plugin.__name == choice.plugin then + plugin.completionCallback(choice) + break + end + end +end + +function obj.choicesCallback() + local query = obj.chooser and obj.chooser:query() or "" + local command, arguments = query:match("^%s*(%S+)%s*(.*)$") + if not command then + return {} + end + local choices = {} + local function append(results) + for _, choice in ipairs(results or {}) do + choices[#choices + 1] = choice + end + end + for _, info in pairs(obj.commands) do + if command:lower() == info.cmd:lower() then + -- Upstream plugins use this sentinel for an empty command argument. + append(info.fn(arguments == "" and ".*" or arguments)) + end + end + for _, plugin in pairs(obj.plugins) do + local bare = plugin:bare() + if bare then + append(bare(query)) + end + end + if arguments == "" then + for name, info in pairs(obj.commands) do + if name:lower():find(command:lower(), 1, true) then + choices[#choices + 1] = { + text = info.name, + subText = info.description, + type = "plugin_cmd", + cmd = info.cmd, + valid = false, + } + end + end + end + table.sort(choices, function(a, b) + return tostring(a.text):lower() < tostring(b.text):lower() + end) + return choices +end + +function obj.queryChangedCallback() + if obj.queryChangedTimer then + obj.queryChangedTimer:stop() + end + obj.queryChangedTimer = hs.timer.doAfter(obj.queryChangedTimerDuration, function() + if obj.chooser:isVisible() then + obj.chooser:refreshChoicesCallback() + end + end) +end + +return obj diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_apps.lua b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_apps.lua new file mode 100644 index 0000000..6546d97 --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_apps.lua @@ -0,0 +1,257 @@ +--- === Seal.plugins.apps === +--- +--- A plugin to add launchable apps/scripts, making Seal act as a launch bar +local obj = {} +obj.__index = obj +obj.__name = "seal_apps" +obj.appCache = {} + +--- Seal.plugins.apps.appSearchPaths +--- Variable +--- Table containing the paths to search for launchable items +--- +--- Notes: +--- * If you change this, you will need to call `spoon.Seal.plugins.apps:restart()` to force Spotlight to search for new items. +obj.appSearchPaths = { + "/Applications", + "/System/Applications", + "~/Applications", + "/Developer/Applications", + "/Applications/Xcode.app/Contents/Applications", + "/System/Library/PreferencePanes", + "/Library/PreferencePanes", + "~/Library/PreferencePanes", + "/System/Library/CoreServices/Applications", + "/System/Library/CoreServices/", + "/usr/local/Cellar", + "/Library/Scripts", + "~/Library/Scripts", +} + +local modifyNameMap = function(info, add) + for _, item in ipairs(info) do + local icon = nil + local displayname = item.kMDItemDisplayName or hs.fs.displayName(item.kMDItemPath) + displayname = displayname:gsub("%.app$", "", 1) + if string.find(item.kMDItemPath, "%.prefPane$") then + displayname = displayname .. " preferences" + if add then + icon = hs.image.iconForFile(item.kMDItemPath) + end + end + if add then + local bundleID = item.kMDItemCFBundleIdentifier + if (not icon) and bundleID then + icon = hs.image.imageFromAppBundle(bundleID) + end + obj.appCache[displayname] = { + path = item.kMDItemPath, + bundleID = bundleID, + icon = icon, + } + else + obj.appCache[displayname] = nil + end + end +end + +local updateNameMap = function(obj, msg, info) + if info then + -- all three can occur in either message, so check them all! + if info.kMDQueryUpdateAddedItems then + modifyNameMap(info.kMDQueryUpdateAddedItems, true) + end + if info.kMDQueryUpdateChangedItems then + modifyNameMap(info.kMDQueryUpdateChangedItems, true) + end + if info.kMDQueryUpdateRemovedItems then + modifyNameMap(info.kMDQueryUpdateRemovedItems, false) + end + else + -- shouldn't happen for didUpdate or inProgress + print("~~~ userInfo from SpotLight was empty for " .. msg) + end +end + +--- Seal.plugins.apps:start() +--- Method +--- Starts the Spotlight app searcher +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * This is called automatically when the plugin is loaded +function obj:start() + obj.spotlight = hs.spotlight + .new() + :queryString( + [[ (kMDItemContentType = "com.apple.application-bundle") || (kMDItemContentType = "com.apple.systempreference.prefpane") || (kMDItemContentType = "com.apple.applescript.text") || (kMDItemContentType = "com.apple.applescript.script") ]] + ) + :callbackMessages("didUpdate", "inProgress") + :setCallback(updateNameMap) + :searchScopes(obj.appSearchPaths) + :start() +end + +--- Seal.plugins.apps:stop() +--- Method +--- Stops the Spotlight app searcher +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +function obj:stop() + obj.spotlight:stop() + obj.spotlight = nil + obj.appCache = {} +end + +--- Seal.plugins.apps:restart() +--- Method +--- Restarts the Spotlight app searcher +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +function obj:restart() + self:stop() + self:start() +end + +hs.application.enableSpotlightForNameSearches(true) +obj:start() + +function obj:commands() + return { + kill = { + cmd = "kill", + fn = obj.choicesKillCommand, + plugin = obj.__name, + name = "Kill", + description = "Kill an application", + }, + reveal = { + cmd = "reveal", + fn = obj.choicesRevealCommand, + plugin = obj.__name, + name = "Reveal", + description = "Reveal an application in the Finder", + }, + } +end + +function obj:bare() + return self.choicesApps +end + +function obj.choicesApps(query) + local choices = {} + if query == nil or query == "" then + return choices + end + for name, app in pairs(obj.appCache) do + if name:lower():find(query == ".*" and "" or query:lower(), 1, true) then + local choice = {} + local instances = {} + if app["bundleID"] then + instances = hs.application.applicationsForBundleID(app["bundleID"]) + end + if #instances > 0 then + choice["text"] = name .. " (Running)" + else + choice["text"] = name + end + choice["subText"] = app["path"] + if app["icon"] then + choice["image"] = app["icon"] + end + choice["path"] = app["path"] + choice["uuid"] = obj.__name .. "__" .. (app["bundleID"] or name) + choice["plugin"] = obj.__name + choice["type"] = "launchOrFocus" + table.insert(choices, choice) + end + end + return choices +end + +function obj.choicesKillCommand(query) + local choices = {} + if query == nil then + return choices + end + local apps = hs.application.runningApplications() + for k, app in pairs(apps) do + local name = app:name() + if + name:lower():find(query == ".*" and "" or query:lower(), 1, true) and app:mainWindow() + then + local choice = {} + choice["text"] = "Kill " .. name + choice["subText"] = app:path() .. " PID: " .. app:pid() + choice["pid"] = app:pid() + choice["plugin"] = obj.__name + choice["type"] = "kill" + choice["image"] = hs.image.imageFromAppBundle(app:bundleID()) + table.insert(choices, choice) + end + end + return choices +end + +function obj.choicesRevealCommand(query) + local choices = {} + if query == nil then + return choices + end + local apps = obj.choicesApps(query) + for k, app in pairs(apps) do + local name = app.text + if name:lower():find(query == ".*" and "" or query:lower(), 1, true) then + local choice = {} + choice["text"] = "Reveal " .. name + choice["path"] = app.path + choice["subText"] = app.path + choice["plugin"] = obj.__name + choice["type"] = "reveal" + if app.image then + choice["image"] = app.image + end + table.insert(choices, choice) + end + end + return choices +end + +function obj.completionCallback(rowInfo) + if rowInfo["type"] == "launchOrFocus" then + if + string.find(rowInfo["path"], "%.applescript$") + or string.find(rowInfo["path"], "%.scpt$") + then + hs.task.new("/usr/bin/osascript", nil, { rowInfo["path"] }):start() + else + hs.task.new("/usr/bin/open", nil, { rowInfo["path"] }):start() + end + elseif rowInfo["type"] == "kill" then + hs.application.get(rowInfo["pid"]):kill() + elseif rowInfo["type"] == "reveal" then + hs.osascript.applescript( + string.format( + [[tell application "Finder" to reveal (POSIX file "%s")]], + rowInfo["path"] + ) + ) + hs.application.launchOrFocus("Finder") + end +end + +return obj diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_calc.lua b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_calc.lua new file mode 100644 index 0000000..24e710f --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_calc.lua @@ -0,0 +1,47 @@ +local obj = {} +obj.__index = obj +obj.__name = "seal_calc" +obj.icon = hs.image.imageFromAppBundle("com.apple.Calculator") + +function obj:commands() + return {} +end + +function obj:bare() + return self.bareCalc +end + +function obj.bareCalc(query) + local choices = {} + if query == nil or query == "" then + return choices + end + + -- Filter out commas and dollar signs + query, _ = query:gsub("[%,%$]", "") + + -- We need to determine if the query only contains mathematical calculations + -- To do this we'll see if it matches the inverse of that set of characters + if string.match(query, "[^%d^%.^%+^%-^/^%*^%^^ ^%(^%)]") == nil then + local choice = {} + local compile_result, fn = load("return " .. query) + if type(compile_result) == "function" then + local result = compile_result() + choice["text"] = result + choice["subText"] = "Copy result to clipboard" + choice["image"] = obj.icon + choice["plugin"] = obj.__name + choice["type"] = "copyToClipboard" + table.insert(choices, choice) + end + end + return choices +end + +function obj.completionCallback(rowInfo) + if rowInfo["type"] == "copyToClipboard" then + hs.pasteboard.setContents(rowInfo["text"]) + end +end + +return obj diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_screencapture.lua b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_screencapture.lua new file mode 100644 index 0000000..27a449c --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_screencapture.lua @@ -0,0 +1,102 @@ +--- === Seal.plugins.screencapture === +--- +--- A plugin to capture the screen in various ways +local obj = {} +obj.__index = obj +obj.__name = "seal_screencapture" + +--- Seal.plugins.screencapture.showPostUI +--- Variable +--- Whether or not to show the screen capture UI in macOS 10.14 or later +obj.showPostUI = true + +local static_choices = { + { + text = "Capture menu", + subText = "Show macOS screen capture menu", + plugin = obj.__name, + type = "screenUI", + }, + { + text = "Capture Screen", + subText = "Capture the current screen", + plugin = obj.__name, + type = "screen", + }, + { + text = "Capture Screen to Clipboard", + subText = "Capture the current screen to the clipboard", + plugin = obj.__name, + type = "screen_clipboard", + }, + { + text = "Capture Interactive", + subText = "Draw a rectangle to capture", + plugin = obj.__name, + type = "interactive", + }, + { + text = "Capture Interactive to Clipboard", + subText = "Draw a rectangle to capture to the clipboard", + plugin = obj.__name, + type = "interactive_clipboard", + }, +} + +function obj:commands() + return { + sc = { + cmd = "sc", + fn = obj.choicesScreenCaptureCommand, + name = "Screencapture", + description = "Capture the screen", + plugin = obj.__name, + }, + } +end + +function obj:bare() + return nil +end + +function obj.choicesScreenCaptureCommand(query) + local choices = {} + + for k, choice in pairs(static_choices) do + if string.match(choice["text"]:lower(), query:lower()) then + table.insert(choices, choice) + end + end + + return choices +end + +function obj.completionCallback(rowInfo) + local filename = hs.fs.pathToAbsolute("~") + .. "/Desktop/Screen Capture at " + .. os.date("!%Y-%m-%d-%T") + .. ".png" + local args = "" + local scType = rowInfo["type"] + + if scType == "screen" then + -- Nothing required here + elseif scType == "screen_clipboard" then + args = "-c" + elseif scType == "interactive" then + args = "-i" + elseif scType == "screenUI" then + args = "-iU" + elseif scType == "interactive_clipboard" then + args = "-ci" + end + + if obj.showPostUI then + args = args .. "u" + end + + print(hs.inspect(args)) + hs.task.new("/usr/sbin/screencapture", nil, { args, filename }):start() +end + +return obj diff --git a/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_useractions.lua b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_useractions.lua new file mode 100644 index 0000000..56c1dba --- /dev/null +++ b/hammerspoon/.hammerspoon/vendored/Launcher.spoon/seal_useractions.lua @@ -0,0 +1,297 @@ +--- ==== Seal.plugins.useractions ==== +--- +--- Allow accessing user-defined bookmarks and arbitrary actions from Seal. +--- + +local obj = {} +obj.__index = obj +obj.__basename = "useractions" +obj.__name = "seal_" .. obj.__basename +obj.default_icon = hs.image.imageFromName(hs.image.systemImageNames.ActionTemplate) + +--- Seal.plugins.useractions.actions +--- Variable +--- +--- Notes: +--- * A table containing the definitions of static user-defined actions. Each entry is indexed by the name of the entry as it will be shown in the chooser. Its value is a table which can have the following keys (one of `fn` or `url` is required. If both are provided, `url` is ignored): +--- * fn - A function which will be called when the entry is selected. The function receives no arguments. +--- * url - A URL which will be opened when the entry is selected. Can also be non-HTTP URLs, such as `mailto:` or other app-specific URLs. +--- * description - (optional) A string or `hs.styledtext` object that will be shown underneath the main text of the choice. +--- * icon - (optional) An `hs.image` object that will be shown next to the entry in the chooser. If not provided, `Seal.plugins.useractions.default_icon` is used. For `url` bookmarks, it can be set to `"favicon"` to fetch and use the website's favicon. +--- * keyword - (optional) A command by which this action will be invoked, effectively turning it into a Seal command. Any arguments passed to the command will be handled as follows: +--- * For `fn` actions, passed as an argument to the function +--- * For `url` actions, substituted into the URL, taking the place of any occurrences of `${query}`. +--- * hotkey - (optional) A hotkey specification in the form `{ modifiers, key }` by which this action can be invoked. +--- * Example configuration: +--- ``` +--- spoon.Seal:loadPlugins({"useractions"}) +--- spoon.Seal.plugins.useractions.actions = +--- { +--- ["Hammerspoon docs webpage"] = { +--- url = "http://hammerspoon.org/docs/", +--- icon = hs.image.imageFromName(hs.image.systemImageNames.ApplicationIcon), +--- description = "Open Hammerspoon documentation", +--- hotkey = { hyper, "h" }, +--- }, +--- ["Leave corpnet"] = { +--- fn = function() +--- spoon.WiFiTransitions:processTransition('foo', 'corpnet01') +--- end, +--- }, +--- ["Arrive in corpnet"] = { +--- fn = function() +--- spoon.WiFiTransitions:processTransition('corpnet01', 'foo') +--- end, +--- }, +--- ["Translate using Leo"] = { +--- url = "http://dict.leo.org/ende/index_de.html#/search=${query}", +--- icon = 'favicon', +--- keyword = "leo", +--- }, +--- ["Tell me something"] = { +--- keyword = "tellme", +--- fn = function(str) hs.alert.show(str) end, +--- } +--- ``` +obj.actions = {} + +--- Seal.plugins.useractions.get_favicon +--- Variable +--- +--- If `true`, attempt to obtain the favicon for URLs added through the `add` command, and use it in the chooser. Defaults to `true` +obj.get_favicon = true + +-- Internal functions for storing/retrieving bookmarks in the settings database. +local getSetting = function(label, default) + return hs.settings.get(obj.__name .. "." .. label) or default +end +local setSetting = function(label, value) + hs.settings.set(obj.__name .. "." .. label, value) + return value +end + +-- Internal variable where the dynamically-added bookmarks are kept +obj.stored_actions = getSetting("stored_actions", {}) + +-- Internal variable where the merged list of bookmarks/actions is kept +obj.all_actions = nil + +function update_all_actions() + if obj.all_actions == nil then + obj.all_actions = {} + for k, v in pairs(obj.actions) do + obj.all_actions[k] = hs.fnutils.copy(v) + end + for k, v in pairs(obj.stored_actions) do + obj.all_actions[k] = hs.fnutils.copy(v) + if v.encoded_icon then + obj.all_actions[k].icon = hs.image.imageFromURL(v.encoded_icon) + end + end + end +end + +function obj:commands() + local cmds = { + add = { + cmd = "add", + fn = obj.choicesAddURLCommand, + name = "Add URL", + description = "Add URL to bookmarks", + plugin = obj.__name, + }, + del = { + cmd = "del", + fn = obj.choicesDelURLCommand, + name = "Delete URL", + description = "Delete URL from bookmarks", + plugin = obj.__name, + }, + } + local hotkeys_def = {} + local hotkeys_map = {} + local any_hotkeys = false + for k, v in pairs(self.actions or {}) do + if v.keyword and not cmds[v.keyword] then + if v.url ~= nil and v.icon == "favicon" then + v.icon = obj.favIcon(v.url) + end + cmds[v.keyword] = { + cmd = v.keyword, + fn = hs.fnutils.partial(obj.choicesActionKeyword, k, v), + name = k, + icon = v.icon, + plugin = obj.__name, + } + end + if v.hotkey then + local choice = obj.buildChoice(k, v) + hotkeys_def[k] = hs.fnutils.partial(obj.completionCallback, choice) + hotkeys_map[k] = v.hotkey + any_hotkeys = true + end + end + if any_hotkeys then + hs.spoons.bindHotkeysToSpec(hotkeys_def, hotkeys_map) + end + return cmds +end + +function obj:bare() + return self.bareActions +end + +function obj.buildChoice(action, v) + local icon, kind + local choice = nil + if type(v) == "table" then + if v.fn then + kind = "runFunction" + elseif v.url then + kind = "openURL" + if v.icon == "favicon" then + v.icon = obj.favIcon(v.url) + end + end + icon = v.icon or obj.default_icon + choice = {} + choice.text = action + choice.type = kind + choice.plugin = obj.__name + choice.image = icon + if v.description then + choice.subText = v.description + end + end + return choice +end + +function obj.bareActions(query) + local choices = {} + if query == nil or query == "" then + return choices + end + + update_all_actions() + obj.seal:refreshCommandsForPlugin(obj.__basename) + + for action, v in pairs(obj.all_actions) do + if action:lower():find(query:lower(), 1, true) then + local choice = obj.buildChoice(action, v) + if choice then + table.insert(choices, choice) + end + end + end + + return choices +end + +function obj.favIcon(url) + local query = string.format( + "http://www.google.com/s2/favicons?sz=64&domain_url=%s", + hs.http.encodeForQuery(url) + ) + return hs.image.imageFromURL(query) +end + +function obj.choicesAddURLCommand(query) + local choices = {} + if query == ".*" then + query = " " + end + local url, name = string.match(query, "([^%s]+)%s+(.*)") + local subtext = "" + if url then + subtext = string.format("New bookmark '%s' pointing to %s", name, url) + end + local choice = { + text = "add " .. query, + subText = subtext, + url = url, + name = name, + plugin = obj.__name, + type = "addURL", + } + table.insert(choices, choice) + return choices +end + +function obj.choicesDelURLCommand(query) + local choices = {} + for k, v in pairs(obj.stored_actions) do + local filter = query == ".*" and "" or query:lower() + if k:lower():find(filter, 1, true) or v.url:lower():find(filter, 1, true) then + local choice = { + text = string.format("delete '%s'", k), + subText = v.url, + delKey = k, + plugin = obj.__name, + type = "delURL", + } + if v.encoded_icon then + choice.image = hs.image.imageFromURL(v.encoded_icon) + end + table.insert(choices, choice) + end + end + return choices +end + +function obj.choicesActionKeyword(action, def, query) + local choices = {} + if query == ".*" then + query = "" + end + local choice = { + text = def.keyword .. " " .. query, + subText = def.description or action, + actionname = action, + arg = query, + plugin = obj.__name, + image = def.icon, + type = "invokeKeyword", + } + table.insert(choices, choice) + return choices +end + +function obj.openURL(url) + hs.execute(string.format("/usr/bin/open '%s'", url)) +end + +function obj.completionCallback(row) + update_all_actions() + if row.type == "runFunction" then + local fn = obj.all_actions[row.text].fn + fn() + elseif row.type == "openURL" then + local url = obj.all_actions[row.text].url + obj.openURL(url) + elseif row.type == "addURL" then + obj.stored_actions[row.name] = { url = row.url } + obj.all_actions = nil + if obj.get_favicon then + local ico = obj.favIcon(row.url) + if ico then + obj.stored_actions[row.name]["encoded_icon"] = ico:encodeAsURLString() + end + end + setSetting("stored_actions", obj.stored_actions) + elseif row.type == "delURL" then + obj.stored_actions[row.delKey] = nil + obj.all_actions = nil + setSetting("stored_actions", obj.stored_actions) + elseif row.type == "invokeKeyword" then + if obj.actions[row.actionname].fn then + obj.actions[row.actionname].fn(row.arg) + elseif obj.actions[row.actionname].url then + row.arg = hs.http.encodeForQuery(row.arg) + local query = row.arg:gsub("%%", "%%%%") + local url = string.gsub(obj.actions[row.actionname].url, "${query}", query) + obj.openURL(url) + end + end +end + +return obj