beatmatchr/Desktop/YabaiPro/Sources/WebRuleEditorViewController.swift
BusyBee3333 7694d965c9 feat: Add structured signal editor with app dropdown and action builder
- Add AppDiscovery provider for running app enumeration
- Implement AppDropdownView with auto-launch functionality
- Create SignalAction models for 40+ yabai commands
- Build ActionBuilderView with nested parameter controls
- Add LiveShellPreview for real-time shell command generation
- Implement ActionValidator for conflict detection
- Add migration parser for existing raw action strings
- Include feature flag for safe rollout
- Maintain full backward compatibility
2025-12-31 01:44:13 -05:00

375 lines
14 KiB
Swift

import Cocoa
import WebKit
import SwiftUI
class WebRuleEditorViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
private var webView: WKWebView!
private var editingRule: YabaiRule?
var onSave: ((YabaiRule) -> Void)?
var onCancel: (() -> Void)?
init(editingRule: YabaiRule? = nil) {
self.editingRule = editingRule
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let webConfiguration = WKWebViewConfiguration()
let userController = WKUserContentController()
// Add script message handlers for communication
userController.add(self, name: "saveRule")
userController.add(self, name: "cancelRule")
webConfiguration.userContentController = userController
webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.navigationDelegate = self
// Enable keyboard input and focus
webView.setValue(false, forKey: "drawsBackground")
self.view = webView
}
override func viewDidLoad() {
super.viewDidLoad()
// Load the HTML content
if let htmlPath = Bundle.main.path(forResource: "rule-editor", ofType: "html") {
let htmlUrl = URL(fileURLWithPath: htmlPath)
webView.loadFileURL(htmlUrl, allowingReadAccessTo: htmlUrl.deletingLastPathComponent())
} else {
// Fallback: inline HTML
loadInlineHTML()
}
// Ensure the web view can receive keyboard input
webView.becomeFirstResponder()
}
private func loadInlineHTML() {
let html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 20px;
background: #f5f5f7;
color: #333;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input, select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input:focus, select:focus {
outline: none;
border-color: #007aff;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
.checkbox-group {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.checkbox-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
.save-btn {
background: #007aff;
color: white;
}
.cancel-btn {
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
button:hover {
opacity: 0.9;
}
.section {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
h3 {
margin-top: 0;
margin-bottom: 10px;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
</head>
<body>
<div class="section">
<h3>Matching Criteria</h3>
<div class="form-group">
<label for="app">Application:</label>
<input type="text" id="app" placeholder="e.g., Safari, Chrome">
</div>
<div class="form-group">
<label for="title">Window Title:</label>
<input type="text" id="title" placeholder="e.g., contains text">
</div>
<div class="form-group">
<label for="role">Role:</label>
<select id="role">
<option value="">Any</option>
<option value="AXWindow">Window</option>
<option value="AXDialog">Dialog</option>
<option value="AXSheet">Sheet</option>
</select>
</div>
<div class="form-group">
<label for="subrole">Subrole:</label>
<select id="subrole">
<option value="">Any</option>
<option value="AXStandardWindow">Standard Window</option>
<option value="AXDialog">Dialog</option>
<option value="AXSystemDialog">System Dialog</option>
</select>
</div>
</div>
<div class="section">
<h3>Actions</h3>
<div class="checkbox-group">
<input type="checkbox" id="manage">
<label for="manage">Manage window</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="sticky">
<label for="sticky">Sticky window</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="mouse_follows_focus">
<label for="mouse_follows_focus">Mouse follows focus</label>
</div>
<div class="form-group">
<label for="layer">Layer:</label>
<select id="layer">
<option value="below">Below</option>
<option value="normal">Normal</option>
<option value="above">Above</option>
</select>
</div>
<div class="form-group">
<label for="opacity">Opacity:</label>
<input type="number" id="opacity" min="0.0" max="1.0" step="0.1" value="1.0">
</div>
<div class="checkbox-group">
<input type="checkbox" id="border">
<label for="border">Show border</label>
</div>
</div>
<div class="button-group">
<button class="cancel-btn" onclick="cancelRule()">Cancel</button>
<button class="save-btn" onclick="saveRule()">Create Rule</button>
</div>
<script>
// Focus first input field on load
document.getElementById('app').focus();
// Load existing rule data if editing
window.onload = function() {
// This would be populated with existing rule data
console.log('Rule editor loaded');
};
function saveRule() {
const rule = {
app: document.getElementById('app').value || null,
title: document.getElementById('title').value || null,
role: document.getElementById('role').value || null,
subrole: document.getElementById('subrole').value || null,
manage: document.getElementById('manage').checked,
sticky: document.getElementById('sticky').checked,
mouse_follows_focus: document.getElementById('mouse_follows_focus').checked,
layer: document.getElementById('layer').value,
opacity: parseFloat(document.getElementById('opacity').value),
border: document.getElementById('border').checked
};
// Send to native app
window.webkit.messageHandlers.saveRule.postMessage(rule);
}
function cancelRule() {
window.webkit.messageHandlers.cancelRule.postMessage({});
}
// Handle Enter key in inputs
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveRule();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelRule();
}
});
</script>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
}
// MARK: - WKScriptMessageHandler
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case "saveRule":
if let ruleDict = message.body as? [String: Any] {
// Extract values to make type-checking easier
let app = ruleDict["app"] as? String
let title = ruleDict["title"] as? String
let role = ruleDict["role"] as? String
let subrole = ruleDict["subrole"] as? String
let manage = (ruleDict["manage"] as? Bool ?? true) ? YabaiRule.ManageState.on : YabaiRule.ManageState.off
let sticky = (ruleDict["sticky"] as? Bool ?? false) ? YabaiRule.StickyState.on : YabaiRule.StickyState.off
let mouseFollowsFocus = ruleDict["mouse_follows_focus"] as? Bool ?? false
let layerString = ruleDict["layer"] as? String ?? "normal"
let layer = YabaiRule.WindowLayer(rawValue: layerString) ?? YabaiRule.WindowLayer.normal
let opacity = ruleDict["opacity"] as? Double ?? 1.0
let border = (ruleDict["border"] as? Bool ?? false) ? YabaiRule.BorderState.on : YabaiRule.BorderState.off
let rule = YabaiRule(
app: app,
title: title,
role: role,
subrole: subrole,
manage: manage,
sticky: sticky,
mouseFollowsFocus: mouseFollowsFocus,
layer: layer,
opacity: opacity,
border: border
)
onSave?(rule)
dismissController()
}
case "cancelRule":
onCancel?()
dismissController()
default:
break
}
}
private func dismissController() {
if let window = view.window {
window.close()
}
}
// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Web view loaded, ensure it can receive keyboard input
DispatchQueue.main.async {
webView.window?.makeFirstResponder(webView)
}
}
}
// MARK: - Window Controller
class WebRuleEditorWindowController: NSWindowController, NSWindowDelegate {
private var webViewController: WebRuleEditorViewController
init(editingRule: YabaiRule? = nil) {
webViewController = WebRuleEditorViewController(editingRule: editingRule)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 600),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
super.init(window: window)
window.center()
window.title = editingRule == nil ? "Create Rule" : "Edit Rule"
window.contentViewController = webViewController
window.isReleasedWhenClosed = false
window.delegate = self
webViewController.onSave = { [weak self] rule in
// Forward to parent window controller if needed
self?.onSave?(rule)
}
webViewController.onCancel = { [weak self] in
self?.onCancel?()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var onSave: ((YabaiRule) -> Void)?
var onCancel: (() -> Void)?
func showWindow() {
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
// Ensure web view gets focus
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let webView = self.webViewController.view as? WKWebView {
self.window?.makeFirstResponder(webView)
}
}
}
// NSWindowDelegate method
func windowWillClose(_ notification: Notification) {
onCancel?()
}
}