beatmatchr/Desktop/YabaiPro/Sources/RuleEditorView.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

233 lines
9.3 KiB
Swift

//
// RuleEditorView.swift
// YabaiPro
//
// Created by Jake Shore
// Copyright © 2024 Jake Shore. All rights reserved.
//
import SwiftUI
struct RuleEditorView: View {
@State private var rule: YabaiRule
@FocusState private var focusedField: Field?
let isEditing: Bool
let onSave: (YabaiRule) -> Void
let onCancel: () -> Void
enum Field: Hashable {
case app, title, role, subrole, opacity
}
init(editingRule: YabaiRule? = nil, onSave: @escaping (YabaiRule) -> Void, onCancel: @escaping () -> Void) {
self._rule = State(initialValue: editingRule ?? YabaiRule())
self.isEditing = editingRule != nil
self.onSave = onSave
self.onCancel = onCancel
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text(isEditing ? "Edit Rule" : "Create Rule")
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(action: onCancel) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.font(.system(size: 24))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
.background(Color(.windowBackgroundColor))
Divider()
// Form content
ScrollView {
Form {
Section("Matching Criteria") {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Application")
.font(.headline)
.foregroundColor(.primary)
TextField("e.g., Safari, Chrome", text: Binding(
get: { rule.app ?? "" },
set: { rule.app = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .app)
}
VStack(alignment: .leading, spacing: 4) {
Text("Window Title")
.font(.headline)
.foregroundColor(.primary)
TextField("e.g., contains text", text: Binding(
get: { rule.title ?? "" },
set: { rule.title = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .title)
}
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Role")
.font(.headline)
.foregroundColor(.primary)
Picker("", selection: Binding(
get: { rule.role ?? "" },
set: { rule.role = $0.isEmpty ? nil : $0 }
)) {
Text("Any").tag("")
Text("Window").tag("AXWindow")
Text("Dialog").tag("AXDialog")
Text("Sheet").tag("AXSheet")
}
.pickerStyle(.menu)
.frame(maxWidth: .infinity)
}
VStack(alignment: .leading, spacing: 4) {
Text("Subrole")
.font(.headline)
.foregroundColor(.primary)
Picker("", selection: Binding(
get: { rule.subrole ?? "" },
set: { rule.subrole = $0.isEmpty ? nil : $0 }
)) {
Text("Any").tag("")
Text("Standard Window").tag("AXStandardWindow")
Text("Dialog").tag("AXDialog")
Text("System Dialog").tag("AXSystemDialog")
}
.pickerStyle(.menu)
.frame(maxWidth: .infinity)
}
}
}
}
Section("Window Behavior") {
VStack(alignment: .leading, spacing: 12) {
Toggle("Manage Window", isOn: Binding(
get: { rule.manage != .off },
set: { rule.manage = $0 ? .on : .off }
))
Toggle("Sticky Window", isOn: Binding(
get: { rule.sticky == .on },
set: { rule.sticky = $0 ? .on : .off }
))
Toggle("Mouse Follows Focus", isOn: Binding(
get: { rule.mouseFollowsFocus ?? false },
set: { rule.mouseFollowsFocus = $0 }
))
VStack(alignment: .leading, spacing: 4) {
Text("Window Layer")
.font(.headline)
.foregroundColor(.primary)
Picker("", selection: Binding(
get: { rule.layer ?? .normal },
set: { rule.layer = $0 }
)) {
Text("Below").tag(YabaiRule.WindowLayer.below)
Text("Normal").tag(YabaiRule.WindowLayer.normal)
Text("Above").tag(YabaiRule.WindowLayer.above)
}
.pickerStyle(.segmented)
}
VStack(alignment: .leading, spacing: 4) {
Text("Opacity: \(String(format: "%.1f", rule.opacity ?? 1.0))")
.font(.headline)
.foregroundColor(.primary)
Slider(value: Binding(
get: { rule.opacity ?? 1.0 },
set: { rule.opacity = $0 }
), in: 0.0...1.0, step: 0.1)
.focused($focusedField, equals: .opacity)
}
// Note: Border control removed - not supported in yabai v7.1.16
}
}
}
// .formStyle(.grouped) - requires macOS 13.0+
.padding(.vertical)
}
Divider()
// Footer with buttons
HStack {
Spacer()
Button(action: onCancel) {
Text("Cancel")
.fontWeight(.medium)
}
.buttonStyle(.bordered)
.keyboardShortcut(.escape)
Button(action: saveRule) {
Text(isEditing ? "Save Changes" : "Create Rule")
.fontWeight(.medium)
}
.buttonStyle(.borderedProminent)
.disabled(!isValidRule)
.keyboardShortcut(.return)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
.background(Color(.windowBackgroundColor))
}
.frame(minWidth: 500, minHeight: 600)
.background(Color(.windowBackgroundColor))
.onAppear {
// Focus first field after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
focusedField = .app
}
}
.onSubmit {
// Handle Tab navigation
switch focusedField {
case .app:
focusedField = .title
case .title:
// Skip to opacity since layer is a segmented control
focusedField = .opacity
case .opacity:
saveRule()
default:
saveRule()
}
}
}
private var isValidRule: Bool {
// At least one matching criterion must be provided
return rule.app != nil || rule.title != nil || rule.role != nil || rule.subrole != nil
}
private func saveRule() {
guard isValidRule else { return }
onSave(rule)
}
}
struct RuleEditorView_Previews: PreviewProvider {
static var previews: some View {
RuleEditorView(onSave: { _ in }, onCancel: { })
}
}