fix(ui): calibrate dashboard no longer overflows window; Escape exits (#163) #164

Merged
gronod merged 1 commits from fix/163-cal-view-overflow into develop 2026-09-13 23:16:34 +01:00
4 changed files with 178 additions and 90 deletions
+121 -88
View File
@@ -7,102 +7,135 @@ struct CalibrationView: View {
@ObservedObject var wizard: WizardViewModel @ObservedObject var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(spacing: 0) {
Text("Calibrate Printer") ScrollView {
.font(.title2.bold()) VStack(alignment: .leading, spacing: 16) {
.padding(.horizontal, 16) Text("Calibrate Printer")
.padding(.top, 16) .font(.title2.bold())
.foregroundStyle(Theme.text)
Form { wedgeSection
Section("Wedge Settings") { workflowSection
Picker("Colour Space", selection: $model.colourSpace) { if !model.calibrationLog.isEmpty { logSection }
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
HStack {
Text("Steps per channel")
Spacer()
TextField("", value: $model.steps, format: .number)
.frame(width: 60)
.accessibilityIdentifier("calSteps")
}
HStack {
Text("White patches")
Spacer()
TextField("", value: $model.whitePatches, format: .number)
.frame(width: 60)
}
if model.colourSpace == .cmyk {
HStack {
Text("Ink-limit exploration")
Spacer()
TextField("", text: $model.inkLimit)
.frame(width: 60)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
}
Section("Workflow") {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.calibrationLog.isEmpty {
Section("Log") {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.calibrationLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
}
}
}
.frame(minHeight: 80, maxHeight: 120)
}
} }
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
} }
.background(Theme.background)
Divider().overlay(Theme.border)
HStack { HStack {
Spacer() Spacer()
Button("Return to Profiling") { model.returnToProfiling() } Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCalReturn") .accessibilityIdentifier("btnCalReturn")
} }
.padding(16) .padding(16)
} }
.accessibilityElement(children: .contain)
.accessibilityIdentifier("stage-cal")
}
// MARK: - Wedge settings
private var wedgeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
.pickerStyle(.segmented)
.frame(maxWidth: 220)
HStack(spacing: 12) {
Text("Steps per channel")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.steps, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calSteps")
}
HStack(spacing: 12) {
Text("White patches")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.whitePatches, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
}
if model.colourSpace == .cmyk {
HStack(spacing: 12) {
Text("Ink-limit exploration")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", text: $model.inkLimit)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("calNeutralEmphasis")
}
}
// MARK: - Workflow
private var workflowSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Log
private var logSection: some View {
ProcessLogView(
lines: model.calibrationLog,
minHeight: 80,
maxHeight: 120,
containerId: "calLogContainer",
logId: "calLog"
)
} }
} }
+1
View File
@@ -39,6 +39,7 @@ struct RootView: View {
} }
WizardStageContent(model: model, workflow: workflow) WizardStageContent(model: model, workflow: workflow)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.background(Theme.background) .background(Theme.background)
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
} }
} }
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
/// rows with expanding spacers once gave the stage an unbounded ideal
/// width, and window centering shifted the 270 pt sidebar into
/// negative X (issue #163). AX-tree existence checks cannot see that,
/// so assert real frame geometry.
func testCalibrationViewDoesNotOverflowWindow() throws {
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
let ret = app.buttons["btnCalReturn"]
XCTAssertTrue(ret.waitForExistence(timeout: 5))
XCTAssertTrue(ret.isHittable)
}
/// "Return to Profiling" is the single Stage 0 exit and carries the
/// cancel-action shortcut, so Escape must dismiss the dashboard too
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
/// runner (m10 phase-08), so the Escape check falls back to the
/// deterministic button tap.
func testCalibrationReturnButtonAndEscapeDismiss() throws {
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
let returnButton = app.buttons["btnCalReturn"]
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
XCTAssertTrue(returnButton.isHittable)
returnButton.tap()
let stage1 = app.descendants(matching: .any)["stage-1"]
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
// Re-enter and try Escape; fall back to the button where the
// runtime does not deliver typeKey.
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
if !stage1.waitForExistence(timeout: 4) {
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
returnButton.tap()
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
}
}
/// A failing calibration targen surfaces the error through the /// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80). /// wizard notice and restores the original basename (issue #80).
func testCalibrationTargenFailureRestoresBasename() throws { func testCalibrationTargenFailureRestoresBasename() throws {
File diff suppressed because one or more lines are too long