mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
cupsadmin: CLI, CupsKit, app in progress
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
335
Sources/CUPSAdminApp/OptionsView.swift
Normal file
335
Sources/CUPSAdminApp/OptionsView.swift
Normal file
@@ -0,0 +1,335 @@
|
||||
import CupsKit
|
||||
import SwiftUI
|
||||
|
||||
/// The web UI's "Set Default Options" page: every PPD option by group, IPP defaults, and queue policies.
|
||||
struct OptionsView: View {
|
||||
@Bindable var model: OptionsModel
|
||||
@State private var showingApplySheet = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.state == nil, let error = model.loadError {
|
||||
ContentUnavailableView("Can’t Load Options", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
} else if model.state == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
form
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) { actionBar }
|
||||
.sheet(isPresented: $showingApplySheet) {
|
||||
ApplyOptionsSheet(model: model) {
|
||||
showingApplySheet = false
|
||||
Task { await model.apply() }
|
||||
}
|
||||
}
|
||||
.alert(item: $model.failure) { failure in
|
||||
Alert(title: Text(failure.title), message: Text(failure.message))
|
||||
}
|
||||
.task(id: model.queue) { await model.load() }
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Text(model.scopeDescription)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if model.isApplying {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if model.hasChanges {
|
||||
Text("\(model.changeCount) unsaved change\(model.changeCount == 1 ? "" : "s")")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.accessibilityIdentifier("options.changeCount")
|
||||
}
|
||||
Button("Revert") { model.revert() }
|
||||
.disabled(!model.hasChanges || model.isApplying)
|
||||
.accessibilityIdentifier("options.revert")
|
||||
Button("Apply…") { showingApplySheet = true }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!model.hasChanges || model.hasErrors || model.isApplying)
|
||||
.accessibilityIdentifier("options.apply")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
Divider()
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(model.sections) { section in
|
||||
Section {
|
||||
ForEach(section.rows) { row in
|
||||
OptionRowView(model: model, row: row)
|
||||
}
|
||||
} header: {
|
||||
Text(section.title)
|
||||
} footer: {
|
||||
if section.title == "IPP" {
|
||||
Text("Queue-wide IPP defaults. On PPD queues, media, sides, output bin, resolution and color follow their PPD option.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(model.customPairs) { pair in
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
StatusIndicator(status: pair.status, pending: pair.status == nil)
|
||||
Button {
|
||||
model.removeCustomPair(pair)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove")
|
||||
.disabled(model.isApplying)
|
||||
}
|
||||
} label: {
|
||||
Text("\(pair.key)=\(pair.value)")
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
.accessibilityIdentifier("options.custom.\(pair.key)")
|
||||
if case .failed(let reason) = pair.status {
|
||||
Text(reason).foregroundStyle(.red)
|
||||
} else if case .applied(let note?) = pair.status {
|
||||
Text(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
HStack {
|
||||
TextField("Add option manually", text: $model.customEntry, prompt: Text("keyword=value"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospaced()
|
||||
.frame(minWidth: 260)
|
||||
.onSubmit { model.addCustomEntry() }
|
||||
.accessibilityIdentifier("options.customEntry")
|
||||
Button("Add") { model.addCustomEntry() }
|
||||
.disabled(model.customEntry.isEmpty || model.isApplying)
|
||||
.accessibilityIdentifier("options.customAdd")
|
||||
}
|
||||
} label: {
|
||||
Text("Add option manually")
|
||||
Text(model.customEntryError ?? "Any lpadmin -o pair, e.g. RIUserCode=Custom.1234")
|
||||
.foregroundStyle(model.customEntryError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red))
|
||||
}
|
||||
} header: {
|
||||
Text("Advanced")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
// Keep the last row clear of the window's status bar.
|
||||
.contentMargins(.bottom, StatusBar.height, for: .scrollContent)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionRowView: View {
|
||||
let model: OptionsModel
|
||||
let row: OptionRow
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
control
|
||||
.accessibilityIdentifier("option.\(row.key)")
|
||||
StatusIndicator(status: model.rowStatus[row.key], pending: model.isPending(row))
|
||||
}
|
||||
.disabled(model.isApplying)
|
||||
} label: {
|
||||
Text(row.label)
|
||||
Text(keywordLine)
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
if let error = model.validationError(row) {
|
||||
Text(error).foregroundStyle(.red)
|
||||
} else if case .failed(let reason) = model.rowStatus[row.key] {
|
||||
Text(reason).foregroundStyle(.red)
|
||||
} else if case .applied(let note?) = model.rowStatus[row.key] {
|
||||
Text(note)
|
||||
} else if case .readOnly(let reason) = row.control {
|
||||
Text(reason)
|
||||
} else if let note = model.overrideNote(row) {
|
||||
Text(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var keywordLine: String {
|
||||
if case .field(let field) = row.control { return "\(row.key) · \(field.hint)" }
|
||||
return row.key
|
||||
}
|
||||
|
||||
@ViewBuilder private var control: some View {
|
||||
switch row.control {
|
||||
case .picker(let choices):
|
||||
Picker(row.label, selection: Binding(
|
||||
get: { model.value(row) ?? "" },
|
||||
set: { model.set(row, to: $0) }
|
||||
)) {
|
||||
let current = model.value(row) ?? ""
|
||||
if !choices.contains(where: { $0.keyword == current }) {
|
||||
Text(current.isEmpty ? "Not set" : current).tag(current)
|
||||
}
|
||||
ForEach(choices, id: \.keyword) { choice in
|
||||
Text(choice.text).tag(choice.keyword)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
case .toggle(let on, let off):
|
||||
Toggle(row.label, isOn: Binding(
|
||||
get: { model.value(row) == on },
|
||||
set: { model.set(row, to: $0 ? on : off) }
|
||||
))
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
|
||||
case .field(let field):
|
||||
let text = Binding(
|
||||
get: { model.fieldText(row, field: field) },
|
||||
set: { model.setFieldText($0, row: row, field: field) }
|
||||
)
|
||||
let prompt = Text(field.choices.first ?? "")
|
||||
Group {
|
||||
if field.isSecret {
|
||||
SecureField(row.label, text: text, prompt: prompt)
|
||||
} else {
|
||||
TextField(row.label, text: text, prompt: prompt)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospacedDigit()
|
||||
.frame(width: field.isNumeric ? 110 : 200)
|
||||
|
||||
case .readOnly:
|
||||
Text(model.state?.display(model.value(row), row: row) ?? "—")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsaved dot, spinner while applying, then a green check or red X after read-back.
|
||||
struct StatusIndicator: View {
|
||||
let status: OptionsModel.RowStatus?
|
||||
let pending: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch status {
|
||||
case .applying:
|
||||
ProgressView().controlSize(.mini)
|
||||
case .applied:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.help("Applied and verified")
|
||||
case .failed(let reason):
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.help(reason)
|
||||
case nil:
|
||||
if pending {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(.tint)
|
||||
.help("Unsaved change")
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
switch status {
|
||||
case .applying: return "Applying"
|
||||
case .applied: return "Applied"
|
||||
case .failed(let reason): return "Not applied: \(reason)"
|
||||
case nil: return pending ? "Unsaved change" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApplyOptionsSheet: View {
|
||||
let model: OptionsModel
|
||||
let apply: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Apply \(model.changeCount) Change\(model.changeCount == 1 ? "" : "s") to \(model.queue)?")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Changes the queue default for every user on this Mac.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let warning = model.secretWarning {
|
||||
Label {
|
||||
Text(warning)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Setting")
|
||||
Text("Current")
|
||||
Text("New")
|
||||
}
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ForEach(model.changeSummaries) { change in
|
||||
GridRow {
|
||||
Text(change.label)
|
||||
Text(change.from).foregroundStyle(.secondary)
|
||||
Text(change.to)
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Command")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(model.command)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("applySheet.cancel")
|
||||
Button("Apply") { apply() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.accessibilityIdentifier("applySheet.apply")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 560)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user